UX: improves composer and thread panel (#21210)
This pull request is a full overhaul of the chat-composer and contains various improvements to the thread panel. They have been grouped in the same PR as lots of improvements/fixes to the thread panel needed an improved composer. This is meant as a first step. ### New features included in this PR - A resizable side panel - A clear dropzone area for uploads - A simplified design for image uploads, this is only a first step towards more redesign of this area in the future ### Notable fixes in this PR - Correct placeholder in thread panel - Allows to edit the last message of a thread with arrow up - Correctly focus composer when replying to a message - The reply indicator is added instantly in the channel when starting a thread - Prevents a large variety of bug where the composer could bug and prevent sending message or would clear your input while it has content ### Technical notes To achieve this PR, three important changes have been made: - `<ChatComposer>` has been fully rewritten and is now a glimmer component - The chat composer now takes a `ChatMessage` as input which can directly be used in other operations, it simplifies a lot of logic as we are always working a with a `ChatMessage` - `TextareaInteractor` has been created to wrap the existing `TextareaTextManipulation` mixin, it will make future migrations easier and allow us to have a less polluted `<ChatComposer>` Note ".chat-live-pane" has been renamed ".chat-channel" Design for upload dropzone is from @chapoi
This commit is contained in:
parent
02625d1edd
commit
bf886662df
|
@ -277,7 +277,7 @@ export default Component.extend({
|
|||
if (fromTopicComposer) {
|
||||
document.querySelector(".d-editor-input")?.focus();
|
||||
} else if (fromChatComposer) {
|
||||
document.querySelector(".chat-composer-input")?.focus();
|
||||
document.querySelector(".chat-composer__input")?.focus();
|
||||
} else {
|
||||
document.querySelector("textarea")?.focus();
|
||||
}
|
||||
|
|
|
@ -216,7 +216,7 @@ export default function (options) {
|
|||
});
|
||||
}
|
||||
|
||||
let completeTerm = async function (term) {
|
||||
let completeTerm = async function (term, event) {
|
||||
let completeEnd = null;
|
||||
|
||||
if (term) {
|
||||
|
@ -228,7 +228,7 @@ export default function (options) {
|
|||
addInputSelectedItem(term, true);
|
||||
} else {
|
||||
if (options.transformComplete) {
|
||||
term = await options.transformComplete(term);
|
||||
term = await options.transformComplete(term, event);
|
||||
}
|
||||
|
||||
if (term) {
|
||||
|
@ -272,7 +272,7 @@ export default function (options) {
|
|||
setCaretPosition(me[0], newCaretPos);
|
||||
|
||||
if (options && options.afterComplete) {
|
||||
options.afterComplete(text);
|
||||
options.afterComplete(text, event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -371,7 +371,7 @@ export default function (options) {
|
|||
} else {
|
||||
selectedOption = -1;
|
||||
}
|
||||
ul.find("li").click(function () {
|
||||
ul.find("li").click(function ({ originalEvent }) {
|
||||
selectedOption = ul.find("li").index(this);
|
||||
// hack for Gboard, see meta.discourse.org/t/-/187009/24
|
||||
if (autocompleteOptions == null) {
|
||||
|
@ -379,13 +379,13 @@ export default function (options) {
|
|||
const forcedAutocompleteOptions = dataSource(prevTerm, opts);
|
||||
forcedAutocompleteOptions?.then((data) => {
|
||||
updateAutoComplete(data);
|
||||
completeTerm(autocompleteOptions[selectedOption]);
|
||||
completeTerm(autocompleteOptions[selectedOption], originalEvent);
|
||||
if (!options.single) {
|
||||
me.focus();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
completeTerm(autocompleteOptions[selectedOption]);
|
||||
completeTerm(autocompleteOptions[selectedOption], originalEvent);
|
||||
if (!options.single) {
|
||||
me.focus();
|
||||
}
|
||||
|
@ -710,7 +710,7 @@ export default function (options) {
|
|||
selectedOption >= 0 &&
|
||||
(userToComplete = autocompleteOptions[selectedOption])
|
||||
) {
|
||||
completeTerm(userToComplete);
|
||||
completeTerm(userToComplete, e);
|
||||
} else {
|
||||
// We're cancelling it, really.
|
||||
return true;
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
<div
|
||||
class={{concat-class
|
||||
"chat-live-pane"
|
||||
"chat-channel"
|
||||
(if this.loading "loading")
|
||||
(if this.chatChannelPane.sendingLoading "sending-loading")
|
||||
(unless this.loadedOnce "not-loaded-once")
|
||||
(if this.chatChannelPane.sending "chat-channel--sending")
|
||||
(unless this.loadedOnce "chat-channel--not-loaded-once")
|
||||
}}
|
||||
{{did-insert this.setUploadDropZone}}
|
||||
{{did-insert this.setupListeners}}
|
||||
{{will-destroy this.teardownListeners}}
|
||||
{{did-insert this.updateChannel}}
|
||||
|
@ -33,7 +34,7 @@
|
|||
>
|
||||
<div
|
||||
class="chat-messages-container"
|
||||
{{chat/on-resize this.didResizePane (hash delay=10)}}
|
||||
{{chat/on-resize this.didResizePane (hash delay=25 immediate=true)}}
|
||||
>
|
||||
{{#if this.loadedOnce}}
|
||||
{{#each @channel.messages key="id" as |message|}}
|
||||
|
@ -78,15 +79,15 @@
|
|||
/>
|
||||
{{else}}
|
||||
{{#if (or @channel.isDraft @channel.isFollowing)}}
|
||||
<ChatComposer
|
||||
@sendMessage={{this.sendMessage}}
|
||||
@chatChannel={{@channel}}
|
||||
@composerService={{this.chatChannelComposer}}
|
||||
@paneService={{this.chatChannelPane}}
|
||||
@context="channel"
|
||||
<Chat::Composer::Channel
|
||||
@channel={{@channel}}
|
||||
@uploadDropZone={{this.uploadDropZone}}
|
||||
@onSendMessage={{this.onSendMessage}}
|
||||
/>
|
||||
{{else}}
|
||||
<ChatChannelPreviewCard @channel={{@channel}} />
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
<ChatUploadDropZone @model={{@channel}} />
|
||||
</div>
|
|
@ -1,7 +1,5 @@
|
|||
import { capitalize } from "@ember/string";
|
||||
import { cloneJSON } from "discourse-common/lib/object";
|
||||
import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
|
||||
import ChatMessageDraft from "discourse/plugins/chat/discourse/models/chat-message-draft";
|
||||
import Component from "@glimmer/component";
|
||||
import { bind, debounce } from "discourse-common/utils/decorators";
|
||||
import { action } from "@ember/object";
|
||||
|
@ -47,12 +45,13 @@ export default class ChatLivePane extends Component {
|
|||
@tracked loading = false;
|
||||
@tracked loadingMorePast = false;
|
||||
@tracked loadingMoreFuture = false;
|
||||
@tracked sendingLoading = false;
|
||||
@tracked sending = false;
|
||||
@tracked showChatQuoteSuccess = false;
|
||||
@tracked includeHeader = true;
|
||||
@tracked hasNewMessages = false;
|
||||
@tracked needsArrow = false;
|
||||
@tracked loadedOnce = false;
|
||||
@tracked uploadDropZone;
|
||||
|
||||
scrollable = null;
|
||||
_loadedChannelId = null;
|
||||
|
@ -60,6 +59,11 @@ export default class ChatLivePane extends Component {
|
|||
_unreachableGroupMentions = [];
|
||||
_overMembersLimitGroupMentions = [];
|
||||
|
||||
@action
|
||||
setUploadDropZone(element) {
|
||||
this.uploadDropZone = element;
|
||||
}
|
||||
|
||||
@action
|
||||
setScrollable(element) {
|
||||
this.scrollable = element;
|
||||
|
@ -107,7 +111,12 @@ export default class ChatLivePane extends Component {
|
|||
if (this._loadedChannelId !== this.args.channel?.id) {
|
||||
this._unsubscribeToUpdates(this._loadedChannelId);
|
||||
this.chatChannelPane.selectingMessages = false;
|
||||
this.chatChannelComposer.cancelEditing();
|
||||
this.chatChannelComposer.message =
|
||||
this.args.channel.draft ||
|
||||
ChatMessage.createDraftMessage(this.args.channel, {
|
||||
user: this.currentUser,
|
||||
});
|
||||
|
||||
this._loadedChannelId = this.args.channel?.id;
|
||||
}
|
||||
|
||||
|
@ -568,15 +577,41 @@ export default class ChatLivePane extends Component {
|
|||
}
|
||||
|
||||
@action
|
||||
sendMessage(message, uploads = []) {
|
||||
resetIdle();
|
||||
|
||||
if (this.chatChannelPane.sendingLoading) {
|
||||
return;
|
||||
onSendMessage(message) {
|
||||
if (message.editing) {
|
||||
this.#sendEditMessage(message);
|
||||
} else {
|
||||
this.#sendNewMessage(message);
|
||||
}
|
||||
}
|
||||
|
||||
this.chatChannelPane.sendingLoading = true;
|
||||
this.args.channel.draft = ChatMessageDraft.create();
|
||||
@action
|
||||
resetComposer() {
|
||||
this.chatChannelComposer.reset(this.args.channel);
|
||||
}
|
||||
|
||||
#sendEditMessage(message) {
|
||||
this.chatChannelPane.sending = true;
|
||||
|
||||
const data = {
|
||||
new_message: message.message,
|
||||
upload_ids: message.uploads.map((upload) => upload.id),
|
||||
};
|
||||
|
||||
this.resetComposer();
|
||||
|
||||
return this.chatApi
|
||||
.editMessage(this.args.channel.id, message.id, data)
|
||||
.catch(popupAjaxError)
|
||||
.finally(() => {
|
||||
this.chatChannelPane.sending = false;
|
||||
});
|
||||
}
|
||||
|
||||
#sendNewMessage(message) {
|
||||
this.chatChannelPane.sending = true;
|
||||
|
||||
resetIdle();
|
||||
|
||||
// TODO: all send message logic is due for massive refactoring
|
||||
// This is all the possible case Im currently aware of
|
||||
|
@ -587,52 +622,38 @@ export default class ChatLivePane extends Component {
|
|||
// - message to a public channel you were tracking (preview = false, not draft)
|
||||
// - message to a channel when we haven't loaded all future messages yet.
|
||||
if (!this.args.channel.isFollowing || this.args.channel.isDraft) {
|
||||
this.loading = true;
|
||||
const data = {
|
||||
message: message.message,
|
||||
upload_ids: message.uploads.map((upload) => upload.id),
|
||||
};
|
||||
|
||||
return this._upsertChannelWithMessage(
|
||||
this.args.channel,
|
||||
message,
|
||||
uploads
|
||||
).finally(() => {
|
||||
this.resetComposer();
|
||||
|
||||
return this._upsertChannelWithMessage(this.args.channel, data).finally(
|
||||
() => {
|
||||
if (this._selfDeleted) {
|
||||
return;
|
||||
}
|
||||
this.loading = false;
|
||||
this.chatChannelPane.sendingLoading = false;
|
||||
this.chatChannelPane.resetAfterSend();
|
||||
this.chatChannelPane.sending = false;
|
||||
this.scrollToLatestMessage();
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const stagedMessage = ChatMessage.createStagedMessage(this.args.channel, {
|
||||
message,
|
||||
created_at: moment.utc().format(),
|
||||
uploads: cloneJSON(uploads),
|
||||
user: this.currentUser,
|
||||
});
|
||||
this.args.channel.stageMessage(message);
|
||||
const stagedMessage = message;
|
||||
this.resetComposer();
|
||||
|
||||
if (this.chatChannelComposer.replyToMsg) {
|
||||
stagedMessage.inReplyTo = this.chatChannelComposer.replyToMsg;
|
||||
}
|
||||
|
||||
if (stagedMessage.inReplyTo) {
|
||||
if (!this.args.channel.threadingEnabled) {
|
||||
this.#messagesManager.addMessages([stagedMessage]);
|
||||
}
|
||||
} else {
|
||||
this.#messagesManager.addMessages([stagedMessage]);
|
||||
}
|
||||
|
||||
if (!this.#messagesManager.canLoadMoreFuture) {
|
||||
if (!this.args.channel.canLoadMoreFuture) {
|
||||
this.scrollToLatestMessage();
|
||||
}
|
||||
|
||||
return this.chatApi
|
||||
.sendMessage(this.args.channel.id, {
|
||||
message: stagedMessage.message,
|
||||
in_reply_to_id: stagedMessage.inReplyTo?.id,
|
||||
staged_id: stagedMessage.id,
|
||||
upload_ids: stagedMessage.uploads.map((upload) => upload.id),
|
||||
message: message.message,
|
||||
in_reply_to_id: message.inReplyTo?.id,
|
||||
staged_id: message.id,
|
||||
upload_ids: message.uploads.map((upload) => upload.id),
|
||||
})
|
||||
.then(() => {
|
||||
this.scrollToLatestMessage();
|
||||
|
@ -645,12 +666,13 @@ export default class ChatLivePane extends Component {
|
|||
if (this._selfDeleted) {
|
||||
return;
|
||||
}
|
||||
this.chatChannelPane.sendingLoading = false;
|
||||
this.chatChannelPane.resetAfterSend();
|
||||
|
||||
this.args.channel.draft = null;
|
||||
this.chatChannelPane.sending = false;
|
||||
});
|
||||
}
|
||||
|
||||
async _upsertChannelWithMessage(channel, message, uploads) {
|
||||
async _upsertChannelWithMessage(channel, data) {
|
||||
let promise = Promise.resolve(channel);
|
||||
|
||||
if (channel.isDirectMessageChannel || channel.isDraft) {
|
||||
|
@ -662,11 +684,9 @@ export default class ChatLivePane extends Component {
|
|||
return promise.then((c) =>
|
||||
ajax(`/chat/${c.id}.json`, {
|
||||
type: "POST",
|
||||
data: {
|
||||
message,
|
||||
upload_ids: (uploads || []).mapBy("id"),
|
||||
},
|
||||
data,
|
||||
}).then(() => {
|
||||
this.chatChannelPane.sending = false;
|
||||
this.router.transitionTo("chat.channel", "-", c.id);
|
||||
})
|
||||
);
|
||||
|
@ -686,12 +706,12 @@ export default class ChatLivePane extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
this.chatChannelPane.resetAfterSend();
|
||||
this.resetComposer();
|
||||
}
|
||||
|
||||
@action
|
||||
resendStagedMessage(stagedMessage) {
|
||||
this.chatChannelPane.sendingLoading = true;
|
||||
this.chatChannelPane.sending = true;
|
||||
|
||||
stagedMessage.error = null;
|
||||
|
||||
|
@ -714,7 +734,7 @@ export default class ChatLivePane extends Component {
|
|||
if (this._selfDeleted) {
|
||||
return;
|
||||
}
|
||||
this.chatChannelPane.sendingLoading = false;
|
||||
this.chatChannelPane.sending = false;
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -824,7 +844,7 @@ export default class ChatLivePane extends Component {
|
|||
return;
|
||||
}
|
||||
|
||||
const composer = document.querySelector(".chat-composer-input");
|
||||
const composer = document.querySelector(".chat-composer__input");
|
||||
if (composer && !this.args.channel.isDraft) {
|
||||
composer.focus();
|
||||
return;
|
||||
|
@ -836,7 +856,7 @@ export default class ChatLivePane extends Component {
|
|||
|
||||
@action
|
||||
computeDatesSeparators() {
|
||||
throttle(this, this._computeDatesSeparators, 50, false);
|
||||
throttle(this, this._computeDatesSeparators, 50, true);
|
||||
}
|
||||
|
||||
// A more consistent way to scroll to the bottom when we are sure this is our goal
|
|
@ -1,16 +1,16 @@
|
|||
<div class="chat-composer-message-details">
|
||||
<div class="chat-reply">
|
||||
{{d-icon this.icon}}
|
||||
<ChatUserAvatar @user={{this.message.user}} />
|
||||
<span class="chat-reply__username">{{this.message.user.username}}</span>
|
||||
{{d-icon @icon}}
|
||||
<ChatUserAvatar @user={{@message.user}} />
|
||||
<span class="chat-reply__username">{{@message.user.username}}</span>
|
||||
<span class="chat-reply__excerpt">
|
||||
{{replace-emoji this.message.excerpt}}
|
||||
{{replace-emoji @message.excerpt}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<FlatButton
|
||||
@action={{this.action}}
|
||||
@class="cancel-message-action"
|
||||
<DButton
|
||||
@action={{@cancelAction}}
|
||||
@class="btn-flat cancel-message-action"
|
||||
@icon="times-circle"
|
||||
@title="cancel"
|
||||
/>
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
import Component from "@ember/component";
|
||||
import Component from "@glimmer/component";
|
||||
|
||||
export default Component.extend({
|
||||
tagName: "",
|
||||
});
|
||||
export default class ChatComposerMessageDetails extends Component {}
|
||||
|
|
|
@ -1,32 +1,37 @@
|
|||
<span class="chat-composer-upload">
|
||||
<span class="preview">
|
||||
{{#if (eq this.type this.IMAGE_TYPE)}}
|
||||
{{#if this.isDone}}
|
||||
<img class="preview-img" src={{this.upload.short_path}} />
|
||||
{{#if @upload}}
|
||||
<div
|
||||
class={{concat-class
|
||||
"chat-composer-upload"
|
||||
(if this.isImage "chat-composer-upload--image")
|
||||
(unless @isDone "chat-composer-upload--in-progress")
|
||||
}}
|
||||
>
|
||||
<div class="preview">
|
||||
{{#if this.isImage}}
|
||||
{{#if @isDone}}
|
||||
<img class="preview-img" src={{@upload.short_path}} />
|
||||
{{else}}
|
||||
{{d-icon "far-image"}}
|
||||
{{/if}}
|
||||
{{else}}
|
||||
{{d-icon "file-alt"}}
|
||||
{{/if}}
|
||||
</span>
|
||||
|
||||
<span class="data">
|
||||
<div class="top-data">
|
||||
<span class="file-name">{{this.fileName}}</span>
|
||||
<DButton
|
||||
@class="btn-flat remove-upload"
|
||||
@action={{this.onCancel}}
|
||||
@icon="times"
|
||||
@title="chat.remove_upload"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span class="data">
|
||||
{{#unless this.isImage}}
|
||||
<div class="top-data">
|
||||
<span class="file-name">{{this.fileName}}</span>
|
||||
</div>
|
||||
{{/unless}}
|
||||
|
||||
<div class="bottom-data">
|
||||
{{#if this.isDone}}
|
||||
<span class="extension-pill">{{this.upload.extension}}</span>
|
||||
{{#if @isDone}}
|
||||
{{#unless this.isImage}}
|
||||
<span class="extension-pill">{{@upload.extension}}</span>
|
||||
{{/unless}}
|
||||
{{else}}
|
||||
{{#if this.upload.processing}}
|
||||
{{#if @upload.processing}}
|
||||
<span class="processing">{{i18n "processing"}}</span>
|
||||
{{else}}
|
||||
<span class="uploading">{{i18n "uploading"}}</span>
|
||||
|
@ -36,9 +41,17 @@
|
|||
class="upload-progress"
|
||||
id="file"
|
||||
max="100"
|
||||
value={{this.upload.progress}}
|
||||
value={{@upload.progress}}
|
||||
></progress>
|
||||
{{/if}}
|
||||
</div>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<DButton
|
||||
@class="btn-flat chat-composer-upload__remove-btn"
|
||||
@action={{@onCancel}}
|
||||
@icon="times"
|
||||
@title="chat.remove_upload"
|
||||
/>
|
||||
</div>
|
||||
{{/if}}
|
|
@ -1,25 +1,16 @@
|
|||
import Component from "@ember/component";
|
||||
import discourseComputed from "discourse-common/utils/decorators";
|
||||
import Component from "@glimmer/component";
|
||||
import { isImage } from "discourse/lib/uploads";
|
||||
|
||||
export default Component.extend({
|
||||
IMAGE_TYPE: "image",
|
||||
|
||||
tagName: "",
|
||||
classNames: "chat-upload",
|
||||
isDone: false,
|
||||
upload: null,
|
||||
onCancel: null,
|
||||
|
||||
@discourseComputed("upload.{original_filename,fileName}")
|
||||
type(upload) {
|
||||
if (isImage(upload.original_filename || upload.fileName)) {
|
||||
return this.IMAGE_TYPE;
|
||||
export default class ChatComposerUpload extends Component {
|
||||
get isImage() {
|
||||
return isImage(
|
||||
this.args.upload.original_filename || this.args.upload.fileName
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed("isDone", "upload.{original_filename,fileName}")
|
||||
fileName(isDone, upload) {
|
||||
return isDone ? upload.original_filename : upload.fileName;
|
||||
},
|
||||
});
|
||||
get fileName() {
|
||||
return this.args.isDone
|
||||
? this.args.upload.original_filename
|
||||
: this.args.upload.fileName;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,18 +22,3 @@
|
|||
@fileInputId={{this.fileUploadElementId}}
|
||||
@fileInputClass="hidden-upload-field"
|
||||
/>
|
||||
|
||||
<div class="drop-a-file">
|
||||
<div class="drop-a-file-content">
|
||||
<div class="drop-a-file-content-images">
|
||||
{{d-icon "file-audio"}}
|
||||
{{d-icon "file-video"}}
|
||||
{{d-icon "file-image"}}
|
||||
</div>
|
||||
|
||||
<p class="drop-a-file-content-text">
|
||||
{{d-icon "upload"}}
|
||||
Drop a file to upload it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
|
@ -16,6 +16,7 @@ export default Component.extend(UppyUploadMixin, {
|
|||
existingUploads: null,
|
||||
uploads: null,
|
||||
useMultipartUploadsIfAvailable: true,
|
||||
uploadDropZone: null,
|
||||
|
||||
init() {
|
||||
this._super(...arguments);
|
||||
|
@ -38,7 +39,7 @@ export default Component.extend(UppyUploadMixin, {
|
|||
|
||||
didInsertElement() {
|
||||
this._super(...arguments);
|
||||
this.composerInputEl = document.querySelector(".chat-composer-input");
|
||||
this.composerInputEl = document.querySelector(".chat-composer__input");
|
||||
this.composerInputEl?.addEventListener("paste", this._pasteEventListener);
|
||||
},
|
||||
|
||||
|
@ -77,19 +78,8 @@ export default Component.extend(UppyUploadMixin, {
|
|||
},
|
||||
|
||||
_uploadDropTargetOptions() {
|
||||
let targetEl;
|
||||
if (this.chatStateManager.isFullPageActive) {
|
||||
targetEl = document.querySelector(".full-page-chat");
|
||||
} else {
|
||||
targetEl = document.querySelector(".chat-drawer.is-expanded");
|
||||
}
|
||||
|
||||
if (!targetEl) {
|
||||
return this._super();
|
||||
}
|
||||
|
||||
return {
|
||||
target: targetEl,
|
||||
target: this.uploadDropZone || document.body,
|
||||
};
|
||||
},
|
||||
|
||||
|
|
|
@ -1,93 +1,105 @@
|
|||
{{#if this.composerService.replyToMsg}}
|
||||
{{! template-lint-disable no-down-event-binding }}
|
||||
<div class="chat-composer__wrapper">
|
||||
{{#if this.shouldRenderMessageDetails}}
|
||||
<ChatComposerMessageDetails
|
||||
@message={{this.composerService.replyToMsg}}
|
||||
@icon="reply"
|
||||
@action={{action "cancelReplyTo"}}
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.composerService.editingMessage}}
|
||||
<ChatComposerMessageDetails
|
||||
@message={{this.composerService.editingMessage}}
|
||||
@message={{if
|
||||
this.currentMessage.editing
|
||||
this.currentMessage
|
||||
this.currentMessage.inReplyTo
|
||||
}}
|
||||
@icon="pencil-alt"
|
||||
@action={{this.cancelEditing}}
|
||||
@cancelAction={{this.onCancel}}
|
||||
/>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
<div
|
||||
<div
|
||||
role="region"
|
||||
aria-label={{i18n "chat.aria_roles.composer"}}
|
||||
class="chat-composer {{if this.disableComposer 'is-disabled'}}"
|
||||
{{did-update this.updateEditingMessage this.composerService.editingMessage}}
|
||||
>
|
||||
|
||||
class={{concat-class
|
||||
"chat-composer"
|
||||
(if this.isFocused "chat-composer--focused")
|
||||
(if this.pane.sending "chat-composer--sending")
|
||||
(if
|
||||
this.sendEnabled
|
||||
"chat-composer--send-enabled"
|
||||
"chat-composer--send-disabled"
|
||||
)
|
||||
(if this.disabled "chat-composer--disabled")
|
||||
}}
|
||||
{{did-update this.didUpdateMessage this.currentMessage}}
|
||||
{{did-update this.didUpdateInReplyTo this.currentMessage.inReplyTo}}
|
||||
{{did-insert this.setupAppEvents}}
|
||||
{{will-destroy this.teardownAppEvents}}
|
||||
>
|
||||
<div class="chat-composer__outer-container">
|
||||
<div class="chat-composer__inner-container">
|
||||
<ChatComposerDropdown
|
||||
@buttons={{this.dropdownButtons}}
|
||||
@isDisabled={{this.disableComposer}}
|
||||
@hasActivePanel={{and
|
||||
this.chatEmojiPickerManager.picker
|
||||
(eq this.chatEmojiPickerManager.picker.context @context)
|
||||
@isDisabled={{this.disabled}}
|
||||
@hasActivePanel={{eq
|
||||
this.chatEmojiPickerManager.picker.context
|
||||
this.context
|
||||
}}
|
||||
@onCloseActivePanel={{this.chatEmojiPickerManager.close}}
|
||||
/>
|
||||
|
||||
<div class="chat-composer__input-container">
|
||||
<DTextarea
|
||||
@value={{readonly this.value}}
|
||||
@input={{action "onTextareaInput" value="target.value"}}
|
||||
@type="text"
|
||||
@class={{concat-class
|
||||
"chat-composer-input"
|
||||
(concat "chat-composer-input--" @context)
|
||||
}}
|
||||
@disabled={{this.disableComposer}}
|
||||
@autocorrect="on"
|
||||
@autocapitalize="sentences"
|
||||
@placeholder={{this.placeholder}}
|
||||
@focus-in={{action "onTextareaFocusIn" value="target"}}
|
||||
@rows={{1}}
|
||||
data-chat-composer-context={{@context}}
|
||||
value={{readonly this.currentMessage.message}}
|
||||
type="text"
|
||||
class="chat-composer__input"
|
||||
disabled={{this.disabled}}
|
||||
autocorrect="on"
|
||||
autocapitalize="sentences"
|
||||
placeholder={{this.placeholder}}
|
||||
rows={{1}}
|
||||
{{did-insert this.setupTextareaInteractor}}
|
||||
{{on "input" this.onInput}}
|
||||
{{on "keydown" this.onKeyDown}}
|
||||
{{on "focusin" this.onTextareaFocusIn}}
|
||||
{{on "focusin" (fn this.computeIsFocused true)}}
|
||||
{{on "focusout" (fn this.computeIsFocused false)}}
|
||||
{{did-insert this.setupAutocomplete}}
|
||||
data-chat-composer-context={{this.context}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{{#if this.isNetworkUnreliable}}
|
||||
<span
|
||||
class="chat-composer__unreliable-network"
|
||||
title={{i18n "chat.unreliable_network"}}
|
||||
>
|
||||
{{d-icon "exclamation-circle"}}
|
||||
</span>
|
||||
{{/if}}
|
||||
|
||||
<FlatButton
|
||||
@action={{action "sendClicked"}}
|
||||
<DButton
|
||||
@action={{this.onSend}}
|
||||
@icon="paper-plane"
|
||||
@class="icon-only send-btn chat-composer-inline-button"
|
||||
@title={{this.sendTitle}}
|
||||
@disabled={{this.sendDisabled}}
|
||||
class="chat-composer__send-btn icon-only"
|
||||
@title="chat.composer.send"
|
||||
@disabled={{or this.disabled (not this.sendEnabled)}}
|
||||
tabindex={{if this.sendEnabled 0 -1}}
|
||||
{{on "focus" (fn this.computeIsFocused true)}}
|
||||
{{on "blur" (fn this.computeIsFocused false)}}
|
||||
/>
|
||||
|
||||
{{#unless this.disableComposer}}
|
||||
{{#unless this.disabled}}
|
||||
<ChatComposerInlineButtons @buttons={{this.inlineButtons}} />
|
||||
{{/unless}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{#if this.canAttachUploads}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{#if this.canAttachUploads}}
|
||||
<ChatComposerUploads
|
||||
@fileUploadElementId={{this.fileUploadElementId}}
|
||||
@onUploadChanged={{this.uploadsChanged}}
|
||||
@existingUploads={{or
|
||||
this.chatChannel.draft.uploads
|
||||
this.composerService.editingMessage.uploads
|
||||
}}
|
||||
@onUploadChanged={{this.onUploadChanged}}
|
||||
@existingUploads={{this.currentMessage.uploads}}
|
||||
@uploadDropZone={{@uploadDropZone}}
|
||||
/>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
{{#unless this.chatChannel.isDraft}}
|
||||
{{#if this.shouldRenderReplyingIndicator}}
|
||||
<div class="chat-replying-indicator-container">
|
||||
<ChatReplyingIndicator @chatChannel={{this.chatChannel}} />
|
||||
<ChatReplyingIndicator @chatChannel={{@channel}} />
|
||||
</div>
|
||||
{{/unless}}
|
||||
{{/if}}
|
||||
|
||||
<ChatEmojiPicker
|
||||
@context={{@context}}
|
||||
@didSelectEmoji={{this.didSelectEmoji}}
|
||||
/>
|
||||
<ChatEmojiPicker
|
||||
@context={{this.context}}
|
||||
@didSelectEmoji={{this.onSelectEmoji}}
|
||||
/>
|
||||
</div>
|
|
@ -1,172 +1,268 @@
|
|||
import { isEmpty } from "@ember/utils";
|
||||
import Component from "@ember/component";
|
||||
import showModal from "discourse/lib/show-modal";
|
||||
import discourseComputed, {
|
||||
afterRender,
|
||||
bind,
|
||||
} from "discourse-common/utils/decorators";
|
||||
import I18n from "I18n";
|
||||
import TextareaTextManipulation from "discourse/mixins/textarea-text-manipulation";
|
||||
import userSearch from "discourse/lib/user-search";
|
||||
import Component from "@glimmer/component";
|
||||
import { action } from "@ember/object";
|
||||
import { cancel, next, schedule, throttle } from "@ember/runloop";
|
||||
import { inject as service } from "@ember/service";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { cancel, next } from "@ember/runloop";
|
||||
import { cloneJSON } from "discourse-common/lib/object";
|
||||
import { chatComposerButtons } from "discourse/plugins/chat/discourse/lib/chat-composer-buttons";
|
||||
import showModal from "discourse/lib/show-modal";
|
||||
import TextareaInteractor from "discourse/plugins/chat/discourse/lib/textarea-interactor";
|
||||
import { getOwner } from "discourse-common/lib/get-owner";
|
||||
import userSearch from "discourse/lib/user-search";
|
||||
import { findRawTemplate } from "discourse-common/lib/raw-templates";
|
||||
import { emojiSearch, isSkinTonableEmoji } from "pretty-text/emoji";
|
||||
import { emojiUrlFor } from "discourse/lib/text";
|
||||
import { inject as service } from "@ember/service";
|
||||
import { reads } from "@ember/object/computed";
|
||||
import { SKIP } from "discourse/lib/autocomplete";
|
||||
import { Promise } from "rsvp";
|
||||
import I18n from "I18n";
|
||||
import { translations } from "pretty-text/emoji/data";
|
||||
import { setupHashtagAutocomplete } from "discourse/lib/hashtag-autocomplete";
|
||||
import {
|
||||
chatComposerButtons,
|
||||
chatComposerButtonsDependentKeys,
|
||||
} from "discourse/plugins/chat/discourse/lib/chat-composer-buttons";
|
||||
import { isEmpty, isPresent } from "@ember/utils";
|
||||
|
||||
const THROTTLE_MS = 150;
|
||||
export default class ChatComposer extends Component {
|
||||
@service capabilities;
|
||||
@service site;
|
||||
@service siteSettings;
|
||||
@service chat;
|
||||
@service chatComposerPresenceManager;
|
||||
@service chatComposerWarningsTracker;
|
||||
@service appEvents;
|
||||
@service chatEmojiReactionStore;
|
||||
@service chatEmojiPickerManager;
|
||||
@service currentUser;
|
||||
@service chatApi;
|
||||
|
||||
export default Component.extend(TextareaTextManipulation, {
|
||||
chatChannel: null,
|
||||
chat: service(),
|
||||
classNames: ["chat-composer-container"],
|
||||
classNameBindings: ["emojiPickerVisible:with-emoji-picker"],
|
||||
chatEmojiReactionStore: service("chat-emoji-reaction-store"),
|
||||
chatEmojiPickerManager: service("chat-emoji-picker-manager"),
|
||||
chatStateManager: service("chat-state-manager"),
|
||||
timer: null,
|
||||
value: "",
|
||||
inProgressUploads: null,
|
||||
composerEventPrefix: "chat",
|
||||
composerFocusSelector: ".chat-composer-input",
|
||||
canAttachUploads: reads("siteSettings.chat_allow_uploads"),
|
||||
isNetworkUnreliable: reads("chat.isNetworkUnreliable"),
|
||||
typingMention: false,
|
||||
chatComposerWarningsTracker: service(),
|
||||
@tracked isFocused = false;
|
||||
|
||||
@discourseComputed(...chatComposerButtonsDependentKeys())
|
||||
inlineButtons() {
|
||||
get shouldRenderReplyingIndicator() {
|
||||
return this.context === "channel" && !this.args.channel?.isDraft;
|
||||
}
|
||||
|
||||
get shouldRenderMessageDetails() {
|
||||
return this.currentMessage?.editing || this.currentMessage?.inReplyTo;
|
||||
}
|
||||
|
||||
get inlineButtons() {
|
||||
return chatComposerButtons(this, "inline", this.context);
|
||||
},
|
||||
}
|
||||
|
||||
@discourseComputed(...chatComposerButtonsDependentKeys())
|
||||
dropdownButtons() {
|
||||
get dropdownButtons() {
|
||||
return chatComposerButtons(this, "dropdown", this.context);
|
||||
},
|
||||
}
|
||||
|
||||
@discourseComputed("chatEmojiPickerManager.{opened,context}")
|
||||
emojiPickerVisible(picker) {
|
||||
return picker.opened && picker.context === "chat-composer";
|
||||
},
|
||||
get fileUploadElementId() {
|
||||
return this.context + "-file-uploader";
|
||||
}
|
||||
|
||||
@discourseComputed("chatStateManager.isFullPageActive")
|
||||
fileUploadElementId(fullPage) {
|
||||
return fullPage ? "chat-full-page-uploader" : "chat-widget-uploader";
|
||||
},
|
||||
|
||||
init() {
|
||||
this._super(...arguments);
|
||||
|
||||
this.appEvents.on(
|
||||
"upload-mixin:chat-composer-uploader:in-progress-uploads",
|
||||
this,
|
||||
"_inProgressUploadsChanged"
|
||||
get canAttachUploads() {
|
||||
return (
|
||||
this.siteSettings.chat_allow_uploads &&
|
||||
isPresent(this.args.uploadDropZone)
|
||||
);
|
||||
}
|
||||
|
||||
this.setProperties({
|
||||
inProgressUploads: [],
|
||||
_uploads: [],
|
||||
});
|
||||
get disabled() {
|
||||
return (
|
||||
(this.args.channel.isDraft &&
|
||||
isEmpty(this.args.channel?.chatable?.users)) ||
|
||||
!this.chat.userCanInteractWithChat ||
|
||||
!this.args.channel.canModifyMessages(this.currentUser)
|
||||
);
|
||||
}
|
||||
|
||||
this.composerService?.registerFocusHandler(() => {
|
||||
this._focusTextArea();
|
||||
});
|
||||
},
|
||||
@action
|
||||
persistDraft() {}
|
||||
|
||||
didInsertElement() {
|
||||
this._super(...arguments);
|
||||
@action
|
||||
setupAutocomplete(textarea) {
|
||||
const $textarea = $(textarea);
|
||||
this.#applyUserAutocomplete($textarea);
|
||||
this.#applyEmojiAutocomplete($textarea);
|
||||
this.#applyCategoryHashtagAutocomplete($textarea);
|
||||
}
|
||||
|
||||
this._textarea = this.element.querySelector(".chat-composer-input");
|
||||
this._$textarea = $(this._textarea);
|
||||
this._applyUserAutocomplete(this._$textarea);
|
||||
this._applyCategoryHashtagAutocomplete(this._$textarea);
|
||||
this._applyEmojiAutocomplete(this._$textarea);
|
||||
this.appEvents.on("chat:insert-text", this, "insertText");
|
||||
this._focusTextArea();
|
||||
@action
|
||||
setupTextareaInteractor(textarea) {
|
||||
this.textareaInteractor = new TextareaInteractor(getOwner(this), textarea);
|
||||
}
|
||||
|
||||
this.appEvents.on("chat:modify-selection", this, "_modifySelection");
|
||||
@action
|
||||
didUpdateMessage() {
|
||||
cancel(this._persistHandler);
|
||||
this.textareaInteractor.value = this.currentMessage.message || "";
|
||||
this.textareaInteractor.focus({ refreshHeight: true });
|
||||
}
|
||||
|
||||
@action
|
||||
didUpdateInReplyTo() {
|
||||
this.textareaInteractor.focus({ ensureAtEnd: true, refreshHeight: true });
|
||||
this.persistDraft();
|
||||
}
|
||||
|
||||
get currentMessage() {
|
||||
return this.composer.message;
|
||||
}
|
||||
|
||||
get hasContent() {
|
||||
const minLength = this.siteSettings.chat_minimum_message_length || 0;
|
||||
return (
|
||||
this.currentMessage?.message?.length > minLength ||
|
||||
(this.canAttachUploads && this.currentMessage?.uploads?.length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
get sendEnabled() {
|
||||
return this.hasContent && !this.pane.sending;
|
||||
}
|
||||
|
||||
@action
|
||||
setupAppEvents() {
|
||||
this.appEvents.on("chat:modify-selection", this, "modifySelection");
|
||||
this.appEvents.on(
|
||||
"chat:open-insert-link-modal",
|
||||
this,
|
||||
"_openInsertLinkModal"
|
||||
"openInsertLinkModal"
|
||||
);
|
||||
document.addEventListener("visibilitychange", this._blurInput);
|
||||
document.addEventListener("resume", this._blurInput);
|
||||
document.addEventListener("freeze", this._blurInput);
|
||||
|
||||
this.set("ready", true);
|
||||
},
|
||||
|
||||
_modifySelection(opts = { type: null, context: null }) {
|
||||
if (opts.context !== this.context) {
|
||||
return;
|
||||
}
|
||||
const sel = this.getSelected("", { lineVal: true });
|
||||
if (opts.type === "bold") {
|
||||
this.applySurround(sel, "**", "**", "bold_text");
|
||||
} else if (opts.type === "italic") {
|
||||
this.applySurround(sel, "_", "_", "italic_text");
|
||||
} else if (opts.type === "code") {
|
||||
this.applySurround(sel, "`", "`", "code_text");
|
||||
}
|
||||
},
|
||||
|
||||
_openInsertLinkModal() {
|
||||
const selected = this.getSelected("", { lineVal: true });
|
||||
const linkText = selected?.value;
|
||||
showModal("insert-hyperlink").setProperties({
|
||||
linkText,
|
||||
toolbarEvent: {
|
||||
addText: (text) => this.addText(selected, text),
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
willDestroyElement() {
|
||||
this._super(...arguments);
|
||||
|
||||
this.appEvents.off(
|
||||
"upload-mixin:chat-composer-uploader:in-progress-uploads",
|
||||
this,
|
||||
"_inProgressUploadsChanged"
|
||||
);
|
||||
|
||||
cancel(this.timer);
|
||||
|
||||
this.appEvents.off("chat:insert-text", this, "insertText");
|
||||
this.appEvents.off("chat:modify-selection", this, "_modifySelection");
|
||||
@action
|
||||
teardownAppEvents() {
|
||||
this.appEvents.off("chat:modify-selection", this, "modifySelection");
|
||||
this.appEvents.off(
|
||||
"chat:open-insert-link-modal",
|
||||
this,
|
||||
"_openInsertLinkModal"
|
||||
"openInsertLinkModal"
|
||||
);
|
||||
document.removeEventListener("visibilitychange", this._blurInput);
|
||||
document.removeEventListener("resume", this._blurInput);
|
||||
document.removeEventListener("freeze", this._blurInput);
|
||||
},
|
||||
}
|
||||
|
||||
// It is important that this is keyDown and not keyUp, otherwise
|
||||
// we add new lines to chat message on send and on edit, because
|
||||
// you cannot prevent default with a keyUp event -- it is like trying
|
||||
// to shut the gate after the horse has already bolted!
|
||||
keyDown(event) {
|
||||
if (this.site.mobileView || event.altKey || event.metaKey) {
|
||||
@action
|
||||
insertDiscourseLocalDate() {
|
||||
showModal("discourse-local-dates-create-modal").setProperties({
|
||||
insertDate: (markup) => {
|
||||
this.textareaInteractor.addText(
|
||||
this.textareaInteractor.getSelected(),
|
||||
markup
|
||||
);
|
||||
this.textareaInteractor.focus();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
uploadClicked() {
|
||||
document.querySelector(`#${this.fileUploadElementId}`).click();
|
||||
}
|
||||
|
||||
@action
|
||||
computeIsFocused(isFocused) {
|
||||
next(() => {
|
||||
this.isFocused = isFocused;
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
onInput(event) {
|
||||
this.currentMessage.message = event.target.value;
|
||||
this.textareaInteractor.refreshHeight();
|
||||
this.reportReplyingPresence();
|
||||
this.persistDraft();
|
||||
this.captureMentions();
|
||||
}
|
||||
|
||||
@action
|
||||
onUploadChanged(uploads, { inProgressUploadsCount }) {
|
||||
if (
|
||||
typeof uploads !== "undefined" &&
|
||||
inProgressUploadsCount !== "undefined" &&
|
||||
inProgressUploadsCount === 0 &&
|
||||
this.currentMessage
|
||||
) {
|
||||
this.currentMessage.uploads = cloneJSON(uploads);
|
||||
}
|
||||
|
||||
this.textareaInteractor.focus();
|
||||
this.reportReplyingPresence();
|
||||
this.persistDraft();
|
||||
}
|
||||
|
||||
@action
|
||||
onSend() {
|
||||
if (!this.sendEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// keyCode for 'Enter'
|
||||
if (event.keyCode === 13) {
|
||||
if (this.site.mobileView) {
|
||||
// prevents to hide the keyboard after sending a message
|
||||
// we use direct DOM manipulation here because textareaInteractor.focus()
|
||||
// is using the runloop which is too late
|
||||
this.textareaInteractor.textarea.focus();
|
||||
}
|
||||
|
||||
this.args.onSendMessage(this.currentMessage);
|
||||
this.textareaInteractor.focus({ refreshHeight: true });
|
||||
}
|
||||
|
||||
@action
|
||||
onCancel() {
|
||||
this.composer.cancel();
|
||||
}
|
||||
|
||||
reportReplyingPresence() {
|
||||
if (this.args.channel.isDraft) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.chatComposerPresenceManager.notifyState(
|
||||
this.args.channel.id,
|
||||
!this.currentMessage.editing && this.hasContent
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
modifySelection(event, options = { type: null, context: null }) {
|
||||
if (options.context !== this.context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sel = this.textareaInteractor.getSelected("", { lineVal: true });
|
||||
if (options.type === "bold") {
|
||||
this.textareaInteractor.applySurround(sel, "**", "**", "bold_text");
|
||||
} else if (options.type === "italic") {
|
||||
this.textareaInteractor.applySurround(sel, "_", "_", "italic_text");
|
||||
} else if (options.type === "code") {
|
||||
this.textareaInteractor.applySurround(sel, "`", "`", "code_text");
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
onTextareaFocusIn(textarea) {
|
||||
if (!this.capabilities.isIOS) {
|
||||
return;
|
||||
}
|
||||
|
||||
// hack to prevent the whole viewport
|
||||
// to move on focus input
|
||||
textarea = document.querySelector(".chat-composer__input");
|
||||
textarea.style.transform = "translateY(-99999px)";
|
||||
textarea.focus();
|
||||
window.requestAnimationFrame(() => {
|
||||
window.requestAnimationFrame(() => {
|
||||
textarea.style.transform = "";
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
onKeyDown(event) {
|
||||
if (
|
||||
this.site.mobileView ||
|
||||
event.altKey ||
|
||||
event.metaKey ||
|
||||
this.#isAutocompleteDisplayed()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Enter") {
|
||||
if (event.shiftKey) {
|
||||
// Shift+Enter: insert newline
|
||||
return;
|
||||
|
@ -175,178 +271,92 @@ export default Component.extend(TextareaTextManipulation, {
|
|||
// Ctrl+Enter, plain Enter: send
|
||||
if (!event.ctrlKey) {
|
||||
// if we are inside a code block just insert newline
|
||||
const { pre } = this.getSelected(null, { lineVal: true });
|
||||
if (this.isInside(pre, /(^|\n)```/g)) {
|
||||
const { pre } = this.textareaInteractor.getSelected({ lineVal: true });
|
||||
if (this.textareaInteractor.isInside(pre, /(^|\n)```/g)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.sendClicked();
|
||||
this.onSend();
|
||||
event.preventDefault();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
event.key === "ArrowUp" &&
|
||||
this._messageIsEmpty() &&
|
||||
!this.composerService?.editingMessage
|
||||
!this.hasContent &&
|
||||
!this.currentMessage.editing
|
||||
) {
|
||||
event.preventDefault();
|
||||
this.paneService?.editLastMessageRequested();
|
||||
const editableMessage = this.pane?.lastCurrentUserMessage;
|
||||
if (editableMessage) {
|
||||
this.composer.editMessage(editableMessage);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
if (this.composerService?.replyToMsg) {
|
||||
this.set("value", "");
|
||||
this.composerService?.setReplyTo(null);
|
||||
if (this.currentMessage?.inReplyTo) {
|
||||
this.reset();
|
||||
return false;
|
||||
} else if (this.composerService?.editingMessage) {
|
||||
this.cancelEditing();
|
||||
} else if (this.currentMessage?.editing) {
|
||||
this.composer.onCancelEditing();
|
||||
return false;
|
||||
} else {
|
||||
this._textarea.blur();
|
||||
event.target.blur();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
didReceiveAttrs() {
|
||||
this._super(...arguments);
|
||||
|
||||
if (
|
||||
!this.composerService?.editingMessage &&
|
||||
this.chatChannel?.draft &&
|
||||
this.chatChannel?.canModifyMessages(this.currentUser)
|
||||
) {
|
||||
// uses uploads from draft here...
|
||||
this.set("value", this.chatChannel.draft.message);
|
||||
this.composerService?.setReplyTo(this.chatChannel.draft.replyToMsg);
|
||||
|
||||
this._captureMentions();
|
||||
this._syncUploads(this.chatChannel.draft.uploads);
|
||||
}
|
||||
|
||||
this.resizeTextarea();
|
||||
},
|
||||
|
||||
@action
|
||||
updateEditingMessage() {
|
||||
if (
|
||||
this.composerService?.editingMessage &&
|
||||
!this.paneService?.sendingLoading
|
||||
) {
|
||||
this.set("value", this.composerService?.editingMessage.message);
|
||||
|
||||
this.composerService?.setReplyTo(null);
|
||||
|
||||
this._syncUploads(this.composerService?.editingMessage.uploads);
|
||||
this._focusTextArea({ ensureAtEnd: true, resizeTextarea: false });
|
||||
reset() {
|
||||
this.composer.reset(this.args.channel);
|
||||
}
|
||||
},
|
||||
|
||||
// the chat-composer needs to be able to set the internal list of uploads
|
||||
// for chat-composer-uploads to preload in existing uploads for drafts
|
||||
// and for when messages are being edited.
|
||||
//
|
||||
// the opposite is true as well -- when an upload is completed the chat-composer
|
||||
// needs its internal state updated so drafts can be saved, which is handled
|
||||
// by the uploadsChanged action
|
||||
_syncUploads(newUploads = []) {
|
||||
const currentUploadIds = this._uploads.mapBy("id");
|
||||
const newUploadIds = newUploads.mapBy("id");
|
||||
|
||||
// don't need to load the uploads into chat-composer-uploads if
|
||||
// nothing has changed otherwise we would rerender for no reason
|
||||
if (
|
||||
currentUploadIds.length === newUploadIds.length &&
|
||||
newUploadIds.every((newUploadId) =>
|
||||
currentUploadIds.includes(newUploadId)
|
||||
)
|
||||
) {
|
||||
@action
|
||||
openInsertLinkModal(event, options = { context: null }) {
|
||||
if (options.context !== this.context) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.set("_uploads", cloneJSON(newUploads));
|
||||
const selected = this.textareaInteractor.getSelected("", { lineVal: true });
|
||||
const linkText = selected?.value;
|
||||
showModal("insert-hyperlink").setProperties({
|
||||
linkText,
|
||||
toolbarEvent: {
|
||||
addText: (text) => this.textareaInteractor.addText(selected, text),
|
||||
},
|
||||
|
||||
_inProgressUploadsChanged(inProgressUploads) {
|
||||
next(() => {
|
||||
if (this.isDestroying || this.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.set("inProgressUploads", inProgressUploads);
|
||||
});
|
||||
},
|
||||
|
||||
@action
|
||||
onTextareaInput(value) {
|
||||
this.set("value", value);
|
||||
this.resizeTextarea();
|
||||
|
||||
this._captureMentions();
|
||||
|
||||
// throttle, not debounce, because we do eventually want to react during the typing
|
||||
this.timer = throttle(this, this._handleTextareaInput, THROTTLE_MS);
|
||||
},
|
||||
|
||||
@bind
|
||||
_handleTextareaInput() {
|
||||
this.composerService?.onComposerValueChange?.({ value: this.value });
|
||||
},
|
||||
|
||||
@bind
|
||||
_captureMentions() {
|
||||
if (this.value) {
|
||||
this.chatComposerWarningsTracker.trackMentions(this.value);
|
||||
}
|
||||
},
|
||||
|
||||
@bind
|
||||
_blurInput() {
|
||||
document.activeElement?.blur();
|
||||
},
|
||||
|
||||
@action
|
||||
uploadClicked() {
|
||||
this.element.querySelector(`#${this.fileUploadElementId}`).click();
|
||||
},
|
||||
|
||||
@bind
|
||||
didSelectEmoji(emoji) {
|
||||
onSelectEmoji(emoji) {
|
||||
const code = `:${emoji}:`;
|
||||
this.chatEmojiReactionStore.track(code);
|
||||
this.addText(this.getSelected(), code);
|
||||
this.textareaInteractor.addText(
|
||||
this.textareaInteractor.getSelected(),
|
||||
code
|
||||
);
|
||||
|
||||
if (this.site.desktopView) {
|
||||
this._focusTextArea();
|
||||
this.textareaInteractor.focus();
|
||||
} else {
|
||||
this.chatEmojiPickerManager.close();
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@action
|
||||
closeComposerDropdown() {
|
||||
this.chatEmojiPickerManager.close();
|
||||
this.appEvents.trigger("d-popover:close");
|
||||
},
|
||||
captureMentions() {
|
||||
if (this.hasContent) {
|
||||
this.chatComposerWarningsTracker.trackMentions(
|
||||
this.currentMessage.message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
insertDiscourseLocalDate() {
|
||||
showModal("discourse-local-dates-create-modal").setProperties({
|
||||
insertDate: (markup) => {
|
||||
this.addText(this.getSelected(), markup);
|
||||
},
|
||||
});
|
||||
},
|
||||
#applyUserAutocomplete($textarea) {
|
||||
if (!this.siteSettings.enable_mentions) {
|
||||
return;
|
||||
}
|
||||
|
||||
// text-area-manipulation mixin override
|
||||
addText() {
|
||||
this._super(...arguments);
|
||||
|
||||
this.resizeTextarea();
|
||||
},
|
||||
|
||||
_applyUserAutocomplete($textarea) {
|
||||
if (this.siteSettings.enable_mentions) {
|
||||
$textarea.autocomplete({
|
||||
template: findRawTemplate("user-selector-autocomplete"),
|
||||
key: "@",
|
||||
|
@ -368,31 +378,32 @@ export default Component.extend(TextareaTextManipulation, {
|
|||
return result;
|
||||
});
|
||||
},
|
||||
afterComplete: (text) => {
|
||||
this.set("value", text);
|
||||
this._focusTextArea();
|
||||
this._captureMentions();
|
||||
afterComplete: (text, event) => {
|
||||
event.preventDefault();
|
||||
this.textareaInteractor.value = text;
|
||||
this.textareaInteractor.focus();
|
||||
this.captureMentions();
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
_applyCategoryHashtagAutocomplete($textarea) {
|
||||
#applyCategoryHashtagAutocomplete($textarea) {
|
||||
setupHashtagAutocomplete(
|
||||
this.site.hashtag_configurations["chat-composer"],
|
||||
$textarea,
|
||||
this.siteSettings,
|
||||
{
|
||||
treatAsTextarea: true,
|
||||
afterComplete: (value) => {
|
||||
this.set("value", value);
|
||||
return this._focusTextArea();
|
||||
afterComplete: (text, event) => {
|
||||
event.preventDefault();
|
||||
this.textareaInteractor.value = text;
|
||||
this.textareaInteractor.focus();
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
}
|
||||
|
||||
_applyEmojiAutocomplete($textarea) {
|
||||
#applyEmojiAutocomplete($textarea) {
|
||||
if (!this.siteSettings.enable_emoji) {
|
||||
return;
|
||||
}
|
||||
|
@ -400,12 +411,12 @@ export default Component.extend(TextareaTextManipulation, {
|
|||
$textarea.autocomplete({
|
||||
template: findRawTemplate("emoji-selector-autocomplete"),
|
||||
key: ":",
|
||||
afterComplete: (text) => {
|
||||
this.set("value", text);
|
||||
this._focusTextArea();
|
||||
afterComplete: (text, event) => {
|
||||
event.preventDefault();
|
||||
this.textareaInteractor.value = text;
|
||||
this.textareaInteractor.focus();
|
||||
},
|
||||
treatAsTextarea: true,
|
||||
|
||||
onKeyUp: (text, cp) => {
|
||||
const matches =
|
||||
/(?:^|[\s.\?,@\/#!%&*;:\[\]{}=\-_()])(:(?!:).?[\w-]*:?(?!:)(?:t\d?)?:?) ?$/gi.exec(
|
||||
|
@ -416,7 +427,6 @@ export default Component.extend(TextareaTextManipulation, {
|
|||
return [matches[1]];
|
||||
}
|
||||
},
|
||||
|
||||
transformComplete: (v) => {
|
||||
if (v.code) {
|
||||
this.chatEmojiReactionStore.track(v.code);
|
||||
|
@ -430,7 +440,6 @@ export default Component.extend(TextareaTextManipulation, {
|
|||
return "";
|
||||
}
|
||||
},
|
||||
|
||||
dataSource: (term) => {
|
||||
return new Promise((resolve) => {
|
||||
const full = `:${term}`;
|
||||
|
@ -472,8 +481,7 @@ export default Component.extend(TextareaTextManipulation, {
|
|||
|
||||
// note this will only work for emojis starting with :
|
||||
// eg: :-)
|
||||
const emojiTranslation =
|
||||
this.get("site.custom_emoji_translation") || {};
|
||||
const emojiTranslation = this.site.custom_emoji_translation || {};
|
||||
const allTranslations = Object.assign(
|
||||
{},
|
||||
translations,
|
||||
|
@ -483,7 +491,7 @@ export default Component.extend(TextareaTextManipulation, {
|
|||
return resolve([allTranslations[full]]);
|
||||
}
|
||||
|
||||
const emojiDenied = this.get("site.denied_emojis") || [];
|
||||
const emojiDenied = this.site.denied_emojis || [];
|
||||
const match = term.match(/^:?(.*?):t([2-6])?$/);
|
||||
if (match) {
|
||||
const name = match[1];
|
||||
|
@ -520,264 +528,9 @@ export default Component.extend(TextareaTextManipulation, {
|
|||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@afterRender
|
||||
_focusTextArea(opts = { ensureAtEnd: false, resizeTextarea: true }) {
|
||||
if (this.chatChannel.isDraft) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._textarea) {
|
||||
return;
|
||||
#isAutocompleteDisplayed() {
|
||||
return document.querySelector(".autocomplete");
|
||||
}
|
||||
|
||||
if (opts.resizeTextarea) {
|
||||
this.resizeTextarea();
|
||||
}
|
||||
|
||||
if (opts.ensureAtEnd) {
|
||||
this._textarea.setSelectionRange(this.value.length, this.value.length);
|
||||
}
|
||||
|
||||
if (this.capabilities.isIpadOS || this.site.mobileView) {
|
||||
return;
|
||||
}
|
||||
|
||||
schedule("afterRender", () => {
|
||||
this._textarea?.focus();
|
||||
});
|
||||
},
|
||||
|
||||
@action
|
||||
onEmojiSelected(code) {
|
||||
this.emojiSelected(code);
|
||||
this.set("emojiPickerIsActive", false);
|
||||
},
|
||||
|
||||
@discourseComputed(
|
||||
"chatChannel.{id,chatable.users.[]}",
|
||||
"chat.userCanInteractWithChat"
|
||||
)
|
||||
disableComposer(channel, userCanInteractWithChat) {
|
||||
return (
|
||||
(channel.isDraft && isEmpty(channel?.chatable?.users)) ||
|
||||
!userCanInteractWithChat ||
|
||||
!channel.canModifyMessages(this.currentUser)
|
||||
);
|
||||
},
|
||||
|
||||
@discourseComputed(
|
||||
"chatChannel.{chatable.users.[],id}",
|
||||
"chat.userCanInteractWithChat"
|
||||
)
|
||||
placeholder(chatChannel, userCanInteractWithChat) {
|
||||
if (!chatChannel.canModifyMessages(this.currentUser)) {
|
||||
return I18n.t(
|
||||
`chat.placeholder_new_message_disallowed.${chatChannel.status}`
|
||||
);
|
||||
}
|
||||
|
||||
if (chatChannel.isDraft) {
|
||||
if (chatChannel?.chatable?.users?.length) {
|
||||
return I18n.t("chat.placeholder_start_conversation_users", {
|
||||
commaSeparatedUsernames: chatChannel.chatable.users
|
||||
.mapBy("username")
|
||||
.join(I18n.t("word_connector.comma")),
|
||||
});
|
||||
} else {
|
||||
return I18n.t("chat.placeholder_start_conversation");
|
||||
}
|
||||
}
|
||||
|
||||
if (!userCanInteractWithChat) {
|
||||
return I18n.t("chat.placeholder_silenced");
|
||||
} else {
|
||||
return this.messageRecipient(chatChannel);
|
||||
}
|
||||
},
|
||||
|
||||
messageRecipient(chatChannel) {
|
||||
if (chatChannel.isDirectMessageChannel) {
|
||||
const directMessageRecipients = chatChannel.chatable.users;
|
||||
if (
|
||||
directMessageRecipients.length === 1 &&
|
||||
directMessageRecipients[0].id === this.currentUser.id
|
||||
) {
|
||||
return I18n.t("chat.placeholder_self");
|
||||
}
|
||||
|
||||
return I18n.t("chat.placeholder_users", {
|
||||
commaSeparatedNames: directMessageRecipients
|
||||
.map((u) => u.name || `@${u.username}`)
|
||||
.join(I18n.t("word_connector.comma")),
|
||||
});
|
||||
} else {
|
||||
return I18n.t("chat.placeholder_channel", {
|
||||
channelName: `#${chatChannel.title}`,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@discourseComputed(
|
||||
"value",
|
||||
"paneService.sendingLoading",
|
||||
"disableComposer",
|
||||
"inProgressUploads.[]"
|
||||
)
|
||||
sendDisabled(value, loading, disableComposer, inProgressUploads) {
|
||||
if (loading || disableComposer || inProgressUploads.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !this._messageIsValid();
|
||||
},
|
||||
|
||||
@action
|
||||
sendClicked() {
|
||||
if (this.site.mobileView) {
|
||||
// prevents android to hide the keyboard after sending a message
|
||||
// we do a focusTextarea later but it's too late for android
|
||||
document.querySelector(this.composerFocusSelector).focus();
|
||||
}
|
||||
|
||||
if (this.sendDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.composerService?.editingMessage
|
||||
? this.internalEditMessage()
|
||||
: this.internalSendMessage();
|
||||
},
|
||||
|
||||
@action
|
||||
internalSendMessage() {
|
||||
// FIXME: This is fairly hacky, we should have a nicer
|
||||
// flow and relationship between the panes for resetting
|
||||
// the value here on send.
|
||||
const _previousValue = this.value;
|
||||
this.set("value", "");
|
||||
return this.sendMessage(_previousValue, this._uploads)
|
||||
.then(this.reset)
|
||||
.catch(() => {
|
||||
this.set("value", _previousValue);
|
||||
});
|
||||
},
|
||||
|
||||
@action
|
||||
internalEditMessage() {
|
||||
return this.paneService
|
||||
?.editMessage(this.value, this._uploads)
|
||||
.then(this.reset);
|
||||
},
|
||||
|
||||
_messageIsValid() {
|
||||
const validLength =
|
||||
(this.value || "").trim().length >=
|
||||
(this.siteSettings.chat_minimum_message_length || 0);
|
||||
|
||||
if (this.canAttachUploads) {
|
||||
if (this._messageIsEmpty()) {
|
||||
// If message is empty, an an upload must present for sending to be enabled
|
||||
return this._uploads.length;
|
||||
} else {
|
||||
// Message is non-empty. Make sure it's long enough to be valid.
|
||||
return validLength;
|
||||
}
|
||||
}
|
||||
|
||||
// Attachments are disabled so for a message to be valid it must be long enough.
|
||||
return validLength;
|
||||
},
|
||||
|
||||
_messageIsEmpty() {
|
||||
return (this.value || "").trim() === "";
|
||||
},
|
||||
|
||||
@action
|
||||
reset() {
|
||||
if (this.isDestroyed || this.isDestroying) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setProperties({
|
||||
value: "",
|
||||
inReplyMsg: null,
|
||||
});
|
||||
this._captureMentions();
|
||||
this._syncUploads([]);
|
||||
this._focusTextArea({ ensureAtEnd: true, resizeTextarea: true });
|
||||
this.composerService?.onComposerValueChange?.(
|
||||
this.value,
|
||||
this._uploads,
|
||||
this.composerService?.replyToMsg
|
||||
);
|
||||
},
|
||||
|
||||
@action
|
||||
cancelReplyTo() {
|
||||
this.composerService?.setReplyTo(null);
|
||||
},
|
||||
|
||||
@action
|
||||
cancelEditing() {
|
||||
this.composerService?.cancelEditing();
|
||||
this.set("value", "");
|
||||
this._focusTextArea({ ensureAtEnd: true, resizeTextarea: true });
|
||||
},
|
||||
|
||||
_cursorIsOnEmptyLine() {
|
||||
const selectionStart = this._textarea.selectionStart;
|
||||
if (selectionStart === 0) {
|
||||
return true;
|
||||
} else if (this._textarea.value.charAt(selectionStart - 1) === "\n") {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
@action
|
||||
uploadsChanged(uploads, { inProgressUploadsCount }) {
|
||||
this.set("_uploads", cloneJSON(uploads));
|
||||
this.composerService?.onComposerValueChange?.({
|
||||
uploads: this._uploads,
|
||||
inProgressUploadsCount,
|
||||
});
|
||||
},
|
||||
|
||||
@action
|
||||
onTextareaFocusIn(target) {
|
||||
if (!this.capabilities.isIOS) {
|
||||
return;
|
||||
}
|
||||
|
||||
// hack to prevent the whole viewport
|
||||
// to move on focus input
|
||||
target = document.querySelector(".chat-composer-input");
|
||||
target.style.transform = "translateY(-99999px)";
|
||||
target.focus();
|
||||
window.requestAnimationFrame(() => {
|
||||
window.requestAnimationFrame(() => {
|
||||
target.style.transform = "";
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
@action
|
||||
resizeTextarea() {
|
||||
schedule("afterRender", () => {
|
||||
if (!this._textarea) {
|
||||
return;
|
||||
}
|
||||
|
||||
// this is a quirk which forces us to `auto` first or textarea
|
||||
// won't resize
|
||||
this._textarea.style.height = "auto";
|
||||
|
||||
// +1 is to workaround a rounding error visible on electron
|
||||
// causing scrollbars to show when they shouldn’t
|
||||
this._textarea.style.height = this._textarea.scrollHeight + 1 + "px";
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
@ -19,6 +19,6 @@
|
|||
/>
|
||||
|
||||
{{#if this.previewedChannel}}
|
||||
<ChatLivePane @channel={{this.previewedChannel}} @includeHeader={{false}} />
|
||||
<ChatChannel @channel={{this.previewedChannel}} @includeHeader={{false}} />
|
||||
{{/if}}
|
||||
</div>
|
|
@ -16,7 +16,7 @@
|
|||
{{did-update this.fetchChannel @params.channelId}}
|
||||
>
|
||||
{{#if this.chat.activeChannel}}
|
||||
<ChatLivePane
|
||||
<ChatChannel
|
||||
@targetMessageId={{readonly @params.messageId}}
|
||||
@channel={{this.chat.activeChannel}}
|
||||
/>
|
||||
|
|
|
@ -210,7 +210,7 @@ export default class ChatMessage extends Component {
|
|||
}
|
||||
|
||||
document.activeElement.blur();
|
||||
document.querySelector(".chat-composer-input")?.blur();
|
||||
document.querySelector(".chat-composer__input")?.blur();
|
||||
|
||||
this._setActiveMessage();
|
||||
}
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
<div class="chat-side-panel-resizer"></div>
|
|
@ -1,5 +1,18 @@
|
|||
{{#if this.chatStateManager.isSidePanelExpanded}}
|
||||
<div class="chat-side-panel">
|
||||
<div
|
||||
class="chat-side-panel"
|
||||
{{did-insert this.setSidePanel}}
|
||||
{{chat/resizable-node
|
||||
".chat-side-panel-resizer"
|
||||
this.didResize
|
||||
(hash position=false vertical=false mutate=false)
|
||||
}}
|
||||
style={{if
|
||||
(and this.site.desktopView this.chatStateManager.isFullPageActive)
|
||||
this.width
|
||||
}}
|
||||
>
|
||||
{{yield}}
|
||||
<ChatSidePanelResizer />
|
||||
</div>
|
||||
{{/if}}
|
|
@ -1,6 +1,49 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { inject as service } from "@ember/service";
|
||||
import { action } from "@ember/object";
|
||||
import { htmlSafe } from "@ember/template";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
|
||||
const MIN_CHAT_CHANNEL_WIDTH = 300;
|
||||
|
||||
export default class ChatSidePanel extends Component {
|
||||
@service chatStateManager;
|
||||
@service chatSidePanelSize;
|
||||
@service site;
|
||||
|
||||
@tracked sidePanel;
|
||||
|
||||
@action
|
||||
setSidePanel(element) {
|
||||
this.sidePanel = element;
|
||||
}
|
||||
|
||||
get width() {
|
||||
if (!this.sidePanel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const maxWidth = Math.min(
|
||||
this.#maxWidth(this.sidePanel),
|
||||
this.chatSidePanelSize.width
|
||||
);
|
||||
|
||||
return htmlSafe(`width:${maxWidth}px`);
|
||||
}
|
||||
|
||||
@action
|
||||
didResize(element, size) {
|
||||
const parentWidth = element.parentElement.getBoundingClientRect().width;
|
||||
const mainPanelWidth = parentWidth - size.width;
|
||||
|
||||
if (mainPanelWidth > MIN_CHAT_CHANNEL_WIDTH) {
|
||||
this.chatSidePanelSize.width = size.width;
|
||||
element.style.width = size.width + "px";
|
||||
}
|
||||
}
|
||||
|
||||
#maxWidth(element) {
|
||||
const parentWidth = element.parentElement.getBoundingClientRect().width;
|
||||
return parentWidth - MIN_CHAT_CHANNEL_WIDTH;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
<div
|
||||
class={{concat-class "chat-thread" (if this.loading "loading")}}
|
||||
data-id={{this.thread.id}}
|
||||
{{did-insert this.setUploadDropZone}}
|
||||
{{did-insert this.subscribeToUpdates}}
|
||||
{{did-insert this.loadMessages}}
|
||||
{{did-update this.subscribeToUpdates this.thread.id}}
|
||||
{{did-update this.loadMessages this.thread.id}}
|
||||
{{did-insert this.setupMessage}}
|
||||
{{will-destroy this.unsubscribeFromUpdates}}
|
||||
>
|
||||
{{#if @includeHeader}}
|
||||
|
@ -13,7 +15,8 @@
|
|||
<LinkTo
|
||||
class="chat-thread__close btn-flat btn btn-icon no-text"
|
||||
@route="chat.channel"
|
||||
@models={{this.chat.activeChannel.routeModels}}
|
||||
@models={{this.channel.routeModels}}
|
||||
title={{i18n "chat.thread.close"}}
|
||||
>
|
||||
{{d-icon "times"}}
|
||||
</LinkTo>
|
||||
|
@ -47,20 +50,20 @@
|
|||
{{#if this.chatChannelThreadPane.selectingMessages}}
|
||||
<ChatSelectionManager
|
||||
@selectedMessageIds={{this.chatChannelThreadPane.selectedMessageIds}}
|
||||
@chatChannel={{this.chat.activeChannel}}
|
||||
@chatChannel={{this.channel}}
|
||||
@cancelSelecting={{action
|
||||
this.chatChannelThreadPane.cancelSelecting
|
||||
this.chat.activeChannel.selectedMessages
|
||||
this.channel.selectedMessages
|
||||
}}
|
||||
@context="thread"
|
||||
/>
|
||||
{{else}}
|
||||
<ChatComposer
|
||||
@sendMessage={{this.sendMessage}}
|
||||
@chatChannel={{this.channel}}
|
||||
@composerService={{this.chatChannelThreadComposer}}
|
||||
@paneService={{this.chatChannelThreadPane}}
|
||||
@context="thread"
|
||||
<Chat::Composer::Thread
|
||||
@channel={{this.channel}}
|
||||
@onSendMessage={{this.onSendMessage}}
|
||||
@uploadDropZone={{this.uploadDropZone}}
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
<ChatUploadDropZone @model={{this.thread}} />
|
||||
</div>
|
|
@ -1,6 +1,4 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { cloneJSON } from "discourse-common/lib/object";
|
||||
import ChatMessageDraft from "discourse/plugins/chat/discourse/models/chat-message-draft";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { action } from "@ember/object";
|
||||
import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
|
||||
|
@ -27,6 +25,7 @@ export default class ChatThreadPanel extends Component {
|
|||
|
||||
@tracked loading;
|
||||
@tracked loadingMorePast;
|
||||
@tracked uploadDropZone;
|
||||
|
||||
scrollable = null;
|
||||
|
||||
|
@ -43,6 +42,19 @@ export default class ChatThreadPanel extends Component {
|
|||
this.chatChannelThreadPaneSubscriptionsManager.subscribe(this.thread);
|
||||
}
|
||||
|
||||
@action
|
||||
setUploadDropZone(element) {
|
||||
this.uploadDropZone = element;
|
||||
}
|
||||
|
||||
@action
|
||||
setupMessage() {
|
||||
this.chatChannelThreadComposer.message = ChatMessage.createDraftMessage(
|
||||
this.channel,
|
||||
{ user: this.currentUser, thread_id: this.thread.id }
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
unsubscribeFromUpdates() {
|
||||
this.chatChannelThreadPaneSubscriptionsManager.unsubscribe();
|
||||
|
@ -168,27 +180,34 @@ export default class ChatThreadPanel extends Component {
|
|||
}
|
||||
|
||||
@action
|
||||
sendMessage(message, uploads = []) {
|
||||
onSendMessage(message) {
|
||||
if (message.editing) {
|
||||
this.#sendEditMessage(message);
|
||||
} else {
|
||||
this.#sendNewMessage(message);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
resetComposer() {
|
||||
this.chatChannelThreadComposer.reset(this.channel);
|
||||
}
|
||||
|
||||
#sendNewMessage(message) {
|
||||
// TODO (martin) For desktop notifications
|
||||
// resetIdle()
|
||||
if (this.chatChannelThreadPane.sendingLoading) {
|
||||
if (this.chatChannelThreadPane.sending) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.chatChannelThreadPane.sendingLoading = true;
|
||||
this.channel.draft = ChatMessageDraft.create();
|
||||
this.chatChannelThreadPane.sending = true;
|
||||
|
||||
// TODO (martin) Handling case when channel is not followed???? IDK if we
|
||||
// even let people send messages in threads without this, seems weird.
|
||||
|
||||
const stagedMessage = ChatMessage.createStagedMessage(this.channel, {
|
||||
message,
|
||||
created_at: new Date(),
|
||||
uploads: cloneJSON(uploads),
|
||||
user: this.currentUser,
|
||||
thread_id: this.thread.id,
|
||||
});
|
||||
|
||||
this.thread.stageMessage(message);
|
||||
const stagedMessage = message;
|
||||
this.resetComposer();
|
||||
this.thread.messagesManager.addMessages([stagedMessage]);
|
||||
|
||||
// TODO (martin) Scrolling!!
|
||||
|
@ -214,8 +233,25 @@ export default class ChatThreadPanel extends Component {
|
|||
if (this._selfDeleted) {
|
||||
return;
|
||||
}
|
||||
this.chatChannelThreadPane.sendingLoading = false;
|
||||
this.chatChannelThreadPane.resetAfterSend();
|
||||
this.chatChannelThreadPane.sending = false;
|
||||
});
|
||||
}
|
||||
|
||||
#sendEditMessage(message) {
|
||||
this.chatChannelThreadPane.sending = true;
|
||||
|
||||
const data = {
|
||||
new_message: message.message,
|
||||
upload_ids: message.uploads.map((upload) => upload.id),
|
||||
};
|
||||
|
||||
this.resetComposer();
|
||||
|
||||
return this.chatApi
|
||||
.editMessage(message.channelId, message.id, data)
|
||||
.catch(popupAjaxError)
|
||||
.finally(() => {
|
||||
this.chatChannelThreadPane.sending = false;
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -302,6 +338,6 @@ export default class ChatThreadPanel extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
this.chatChannelThreadPane.resetAfterSend();
|
||||
this.resetComposer();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,65 @@
|
|||
<div class="chat-upload-drop-zone">
|
||||
<div class="chat-upload-drop-zone__content">
|
||||
<div class="chat-upload-drop-zone__background">
|
||||
<svg
|
||||
width="94"
|
||||
height="90"
|
||||
viewBox="0 0 94 90"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M94 40.5591C94 69.8685 64.0686 90 40.9592 90C17.8499 90 0 83.9085 0 60.6907C0 37.4729 28.458 0 51.5674 0C74.6768 0 94 17.3413 94 40.5591Z"
|
||||
fill="#D1F0FF"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="chat-upload-drop-zone__illustration">
|
||||
<svg
|
||||
width="106"
|
||||
height="84"
|
||||
viewBox="0 0 106 84"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect
|
||||
x="55.832"
|
||||
y="6.82959"
|
||||
width="45.7854"
|
||||
height="33.8408"
|
||||
transform="rotate(13.4039 55.832 6.82959)"
|
||||
fill="#D9D9D9"
|
||||
></rect>
|
||||
<path
|
||||
d="M100.66 13.7645L61.1414 4.34705C58.6715 3.75846 56.1786 5.37298 55.59 7.84288L47.6214 41.2815C47.0102 43.8464 48.5297 46.3167 50.9996 46.9053L90.518 56.3227C92.9879 56.9113 95.5532 55.4145 96.1644 52.8496L104.133 19.4109C104.722 16.941 103.225 14.3757 100.66 13.7645ZM58.7093 41.5144C58.5961 41.9894 58.1482 42.1838 57.7682 42.0933L53.2084 41.0067C52.7334 40.8935 52.5163 40.5406 52.6295 40.0656L53.7161 35.5058C53.8067 35.1258 54.1822 34.8137 54.6572 34.9269L59.122 35.9909C59.502 36.0815 59.7915 36.552 59.7009 36.932L58.7093 41.5144ZM61.6069 29.3549C61.4938 29.8299 61.0459 30.0243 60.6659 29.9338L56.1061 28.8472C55.6311 28.734 55.414 28.3811 55.5272 27.9061L56.6138 23.3463C56.7044 22.9663 57.0799 22.6542 57.5549 22.7674L62.0197 23.8314C62.3997 23.922 62.6891 24.3925 62.5986 24.7725L61.5119 29.3323L61.6069 29.3549ZM64.5046 17.1954C64.3914 17.6704 63.9435 17.8648 63.5635 17.7743L59.0037 16.6877C58.5287 16.5745 58.3117 16.2216 58.4249 15.7466L59.5115 11.1868C59.602 10.8068 59.9776 10.4947 60.4526 10.6079L64.9174 11.6719C65.2974 11.7625 65.5868 12.233 65.4962 12.613L64.5046 17.1954ZM81.6894 46.1876C81.4857 47.0426 80.5673 47.5264 79.8073 47.3453L64.6079 43.7232C63.753 43.5195 63.2464 42.6961 63.4502 41.8411L65.6234 32.7215C65.8045 31.9615 66.6506 31.36 67.5056 31.5637L82.705 35.1858C83.4649 35.3669 84.0438 36.308 83.8627 37.068L81.6894 46.1876ZM86.036 27.9483C85.8322 28.8033 84.9138 29.2872 84.1538 29.1061L68.9544 25.484C68.0995 25.2802 67.593 24.4568 67.7967 23.6018L69.97 14.4822C70.1511 13.7222 70.9971 13.1207 71.8521 13.3245L87.0515 16.9466C87.8114 17.1277 88.3903 18.0687 88.2092 18.8287L86.036 27.9483ZM92.1479 49.483C92.0347 49.958 91.5868 50.1524 91.2068 50.0619L86.742 48.9979C86.267 48.8847 86.05 48.5318 86.1631 48.0568L87.2498 43.497C87.3403 43.117 87.8109 42.8276 88.1908 42.9181L92.7507 44.0048C93.1306 44.0953 93.4201 44.5659 93.3295 44.9458L92.2429 49.5057L92.1479 49.483ZM95.0456 37.3235C94.9324 37.7985 94.4845 37.9929 94.1045 37.9024L89.6397 36.8384C89.1647 36.7252 88.9476 36.3723 89.0608 35.8973L90.1474 31.3375C90.238 30.9575 90.6135 30.6455 91.0885 30.7586L95.5533 31.8226C95.9333 31.9132 96.2228 32.3837 96.1322 32.7637L95.0456 37.3235ZM97.9432 25.164C97.8301 25.639 97.3822 25.8334 97.0022 25.7429L92.5374 24.6789C92.0624 24.5657 91.8453 24.2128 91.9585 23.7378L93.0451 19.178C93.1357 18.798 93.5112 18.486 93.8912 18.5765L98.356 19.6405C98.736 19.731 99.0254 20.2016 98.9349 20.5816L97.8483 25.1414L97.9432 25.164Z"
|
||||
fill="#AFAFAF"
|
||||
></path>
|
||||
<path
|
||||
d="M30.7898 24.814L27.2823 9.2672L4.41944 14.4252C2.81904 14.7863 1.95958 16.3017 2.29486 17.7878L14.2615 70.8296C14.6226 72.43 16.0236 73.3153 17.624 72.9542L56.0337 64.2887C57.5198 63.9534 58.5193 62.5266 58.1582 60.9262L49.699 23.4311L34.1523 26.9385C32.5519 27.2996 31.1508 26.4144 30.7898 24.814ZM48.719 19.0871C48.5643 18.4012 48.0666 17.7927 47.4804 17.3243L33.7501 8.64894C33.0754 8.32063 32.3121 8.13243 31.6263 8.28717L30.9404 8.44191L34.2415 23.0742L48.8738 19.773L48.719 19.0871Z"
|
||||
fill="#0AADFF"
|
||||
></path>
|
||||
<rect
|
||||
x="41.7334"
|
||||
y="40.3967"
|
||||
width="37.6309"
|
||||
height="28.6511"
|
||||
transform="rotate(6.29289 41.7334 40.3967)"
|
||||
fill="#66CCFF"
|
||||
></rect>
|
||||
<path
|
||||
d="M76.768 40.4721L44.4638 36.9097C42.3671 36.6785 40.548 38.2071 40.3254 40.2261L37.8591 62.5905C37.6279 64.6872 39.0788 66.4977 41.1755 66.729L73.4796 70.2913C75.4987 70.514 77.3869 69.0716 77.6181 66.9749L80.0843 44.6105C80.307 42.5915 78.787 40.6947 76.768 40.4721ZM73.4248 66.5125L42.0524 63.0529C41.7418 63.0187 41.6036 62.8462 41.6379 62.5356L44.0014 41.103C44.0271 40.8701 44.2081 40.6542 44.5187 40.6885L75.891 44.1481C76.124 44.1738 76.3312 44.4324 76.3056 44.6654L73.9421 66.098C73.9078 66.4086 73.6577 66.5382 73.4248 66.5125ZM49.9226 44.4284C48.1365 44.2314 46.6623 45.4836 46.4739 47.192C46.2769 48.978 47.4514 50.4437 49.2375 50.6407C50.9459 50.8291 52.4892 49.6631 52.6862 47.8771C52.8746 46.1687 51.631 44.6168 49.9226 44.4284ZM45.725 59.6852L70.5743 62.4255L71.2594 56.2131L65.1708 48.7036C64.8254 48.2725 64.2818 48.2126 63.8507 48.558L53.5908 56.7799L50.8186 53.4088C50.4732 52.9777 49.9296 52.9178 49.4985 53.2632L46.136 55.9578L45.725 59.6852Z"
|
||||
fill="white"
|
||||
></path>
|
||||
<path
|
||||
d="M37.8174 63.0181L77.5892 66.862L77.012 72.8342C76.9057 73.9336 75.9283 74.7388 74.8288 74.6325L39.0385 71.1734C37.939 71.0671 37.1339 70.0897 37.2402 68.9902L37.8174 63.0181Z"
|
||||
fill="white"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="chat-upload-drop-zone__text">
|
||||
<span class="chat-upload-drop-zone__text__title">
|
||||
{{this.title}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,19 @@
|
|||
import Component from "@glimmer/component";
|
||||
import ChatThread from "discourse/plugins/chat/discourse/models/chat-thread";
|
||||
import I18n from "I18n";
|
||||
|
||||
export default class ChatUploadDropZone extends Component {
|
||||
get title() {
|
||||
if (this.#isThread()) {
|
||||
return I18n.t("chat.upload_to_thread");
|
||||
} else {
|
||||
return I18n.t("chat.upload_to_channel", {
|
||||
title: this.args.model.title,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#isThread() {
|
||||
return this.args.model instanceof ChatThread;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,95 @@
|
|||
import ChatComposer from "../../chat-composer";
|
||||
import { inject as service } from "@ember/service";
|
||||
import I18n from "I18n";
|
||||
import discourseDebounce from "discourse-common/lib/debounce";
|
||||
import { action } from "@ember/object";
|
||||
import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
|
||||
import { Promise } from "rsvp";
|
||||
|
||||
export default class ChatComposerChannel extends ChatComposer {
|
||||
@service("chat-channel-composer") composer;
|
||||
@service("chat-channel-pane") pane;
|
||||
|
||||
context = "channel";
|
||||
|
||||
@action
|
||||
sendMessage(raw) {
|
||||
const message = ChatMessage.createDraftMessage(this.args.channel, {
|
||||
user: this.currentUser,
|
||||
message: raw,
|
||||
});
|
||||
|
||||
this.args.onSendMessage(message);
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
@action
|
||||
persistDraft() {
|
||||
if (this.args.channel?.isDraft) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._persistHandler = discourseDebounce(
|
||||
this,
|
||||
this._debouncedPersistDraft,
|
||||
2000
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
_debouncedPersistDraft() {
|
||||
this.chatApi.saveDraft(
|
||||
this.args.channel.id,
|
||||
this.currentMessage.toJSONDraft()
|
||||
);
|
||||
}
|
||||
|
||||
get placeholder() {
|
||||
if (!this.args.channel.canModifyMessages(this.currentUser)) {
|
||||
return I18n.t(
|
||||
`chat.placeholder_new_message_disallowed.${this.args.channel.status}`
|
||||
);
|
||||
}
|
||||
|
||||
if (this.args.channel.isDraft) {
|
||||
if (this.args.channel?.chatable?.users?.length) {
|
||||
return I18n.t("chat.placeholder_start_conversation_users", {
|
||||
commaSeparatedUsernames: this.args.channel.chatable.users
|
||||
.mapBy("username")
|
||||
.join(I18n.t("word_connector.comma")),
|
||||
});
|
||||
} else {
|
||||
return I18n.t("chat.placeholder_start_conversation");
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.chat.userCanInteractWithChat) {
|
||||
return I18n.t("chat.placeholder_silenced");
|
||||
} else {
|
||||
return this.#messageRecipients(this.args.channel);
|
||||
}
|
||||
}
|
||||
|
||||
#messageRecipients(channel) {
|
||||
if (channel.isDirectMessageChannel) {
|
||||
const directMessageRecipients = channel.chatable.users;
|
||||
if (
|
||||
directMessageRecipients.length === 1 &&
|
||||
directMessageRecipients[0].id === this.currentUser.id
|
||||
) {
|
||||
return I18n.t("chat.placeholder_self");
|
||||
}
|
||||
|
||||
return I18n.t("chat.placeholder_users", {
|
||||
commaSeparatedNames: directMessageRecipients
|
||||
.map((u) => u.name || `@${u.username}`)
|
||||
.join(I18n.t("word_connector.comma")),
|
||||
});
|
||||
} else {
|
||||
return I18n.t("chat.placeholder_channel", {
|
||||
channelName: `#${channel.title}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
import ChatComposer from "../../chat-composer";
|
||||
import { inject as service } from "@ember/service";
|
||||
import I18n from "I18n";
|
||||
import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
|
||||
import { Promise } from "rsvp";
|
||||
import { action } from "@ember/object";
|
||||
|
||||
export default class ChatComposerThread extends ChatComposer {
|
||||
@service("chat-channel-thread-composer") composer;
|
||||
@service("chat-channel-thread-pane") pane;
|
||||
|
||||
context = "thread";
|
||||
|
||||
@action
|
||||
sendMessage(raw) {
|
||||
const message = ChatMessage.createDraftMessage(this.args.channel, {
|
||||
user: this.currentUser,
|
||||
message: raw,
|
||||
thread_id: this.args.channel.activeThread.id,
|
||||
});
|
||||
|
||||
this.args.onSendMessage(message);
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
get placeholder() {
|
||||
return I18n.t("chat.placeholder_thread");
|
||||
}
|
||||
}
|
|
@ -163,7 +163,7 @@ export default Component.extend({
|
|||
@action
|
||||
handleFilterKeyUp(event) {
|
||||
if (event.key === "Tab") {
|
||||
const enabledComposer = document.querySelector(".chat-composer-input");
|
||||
const enabledComposer = document.querySelector(".chat-composer__input");
|
||||
if (enabledComposer && !enabledComposer.disabled) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{{#if @channel.id}}
|
||||
<ChatLivePane
|
||||
<ChatChannel
|
||||
@channel={{@channel}}
|
||||
@targetMessageId={{readonly @targetMessageId}}
|
||||
/>
|
||||
|
|
|
@ -8,15 +8,21 @@ registerUnbound("format-chat-date", function (message, mode) {
|
|||
const currentUser = User.current();
|
||||
const tz = currentUser ? currentUser.user_option.timezone : moment.tz.guess();
|
||||
const date = moment(new Date(message.createdAt), tz);
|
||||
const url = getURL(`/chat/c/-/${message.channelId}/${message.id}`);
|
||||
const title = date.format(I18n.t("dates.long_with_year"));
|
||||
|
||||
const title = date.format(I18n.t("dates.long_with_year"));
|
||||
const display =
|
||||
mode === "tiny"
|
||||
? date.format(I18n.t("chat.dates.time_tiny"))
|
||||
: date.format(I18n.t("dates.time"));
|
||||
|
||||
if (message.staged) {
|
||||
return htmlSafe(
|
||||
`<span title='${title}' class='chat-time'>${display}</span>`
|
||||
);
|
||||
} else {
|
||||
const url = getURL(`/chat/c/-/${message.channel.id}/${message.id}`);
|
||||
return htmlSafe(
|
||||
`<a title='${title}' class='chat-time' href='${url}'>${display}</a>`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
@ -1,33 +0,0 @@
|
|||
import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
|
||||
import { generateCookFunction } from "discourse/lib/text";
|
||||
import simpleCategoryHashMentionTransform from "discourse/plugins/chat/discourse/lib/simple-category-hash-mention-transform";
|
||||
|
||||
export default {
|
||||
name: "chat-cook-function",
|
||||
|
||||
before: "chat-setup",
|
||||
|
||||
initialize(container) {
|
||||
const site = container.lookup("service:site");
|
||||
|
||||
const markdownOptions = {
|
||||
featuresOverride:
|
||||
site.markdown_additional_options?.chat?.limited_pretty_text_features,
|
||||
markdownItRules:
|
||||
site.markdown_additional_options?.chat
|
||||
?.limited_pretty_text_markdown_rules,
|
||||
hashtagTypesInPriorityOrder:
|
||||
site.hashtag_configurations?.["chat-composer"],
|
||||
hashtagIcons: site.hashtag_icons,
|
||||
};
|
||||
|
||||
generateCookFunction(markdownOptions).then((cookFunction) => {
|
||||
ChatMessage.cookFunction = (raw) => {
|
||||
return simpleCategoryHashMentionTransform(
|
||||
cookFunction(raw),
|
||||
site.categories
|
||||
);
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
|
@ -42,7 +42,8 @@ export default {
|
|||
chatService.switchChannelUpOrDown("down");
|
||||
};
|
||||
|
||||
const isChatComposer = (el) => el.classList.contains("chat-composer-input");
|
||||
const isChatComposer = (el) =>
|
||||
el.classList.contains("chat-composer__input");
|
||||
const isInputSelection = (el) => {
|
||||
const inputs = ["input", "textarea", "select", "button"];
|
||||
const elementTagName = el?.tagName.toLowerCase();
|
||||
|
@ -58,7 +59,7 @@ export default {
|
|||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
appEvents.trigger("chat:modify-selection", {
|
||||
appEvents.trigger("chat:modify-selection", event, {
|
||||
type,
|
||||
context: event.target.dataset.chatComposerContext,
|
||||
});
|
||||
|
@ -70,7 +71,9 @@ export default {
|
|||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
appEvents.trigger("chat:open-insert-link-modal", { event });
|
||||
appEvents.trigger("chat:open-insert-link-modal", event, {
|
||||
context: event.target.dataset.chatComposerContext,
|
||||
});
|
||||
};
|
||||
|
||||
const openChatDrawer = (event) => {
|
||||
|
|
|
@ -6,7 +6,7 @@ export default function chatMessageContainer(id, context) {
|
|||
if (context === MESSAGE_CONTEXT_THREAD) {
|
||||
selector = `.chat-thread .chat-message-container[data-id="${id}"]`;
|
||||
} else {
|
||||
selector = `.chat-live-pane .chat-message-container[data-id="${id}"]`;
|
||||
selector = `.chat-channel .chat-message-container[data-id="${id}"]`;
|
||||
}
|
||||
|
||||
return document.querySelector(selector);
|
||||
|
|
|
@ -349,12 +349,12 @@ export default class ChatMessageInteractor {
|
|||
|
||||
@action
|
||||
reply() {
|
||||
this.composer.setReplyTo(this.message.id);
|
||||
this.composer.replyTo(this.message);
|
||||
}
|
||||
|
||||
@action
|
||||
edit() {
|
||||
this.composer.editButtonClicked(this.message.id);
|
||||
this.composer.editMessage(this.message);
|
||||
}
|
||||
|
||||
@action
|
||||
|
|
|
@ -0,0 +1,72 @@
|
|||
import EmberObject from "@ember/object";
|
||||
import TextareaTextManipulation from "discourse/mixins/textarea-text-manipulation";
|
||||
import { next, schedule } from "@ember/runloop";
|
||||
import { setOwner } from "@ember/application";
|
||||
import { inject as service } from "@ember/service";
|
||||
|
||||
// This class sole purpose is to provide a way to interact with the textarea
|
||||
// using the existing TextareaTextManipulation mixin without using it directly
|
||||
// in the composer component. It will make future migration easier.
|
||||
export default class TextareaInteractor extends EmberObject.extend(
|
||||
TextareaTextManipulation
|
||||
) {
|
||||
@service capabilities;
|
||||
@service site;
|
||||
|
||||
constructor(owner, textarea) {
|
||||
super(...arguments);
|
||||
setOwner(this, owner);
|
||||
this.textarea = textarea;
|
||||
this._textarea = textarea;
|
||||
this.element = this._textarea;
|
||||
this.ready = true;
|
||||
}
|
||||
|
||||
set value(value) {
|
||||
this._textarea.value = value;
|
||||
const event = new Event("input", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
this._textarea.dispatchEvent(event);
|
||||
}
|
||||
|
||||
focus(opts = { ensureAtEnd: false, refreshHeight: true }) {
|
||||
next(() => {
|
||||
if (opts.refreshHeight) {
|
||||
this.refreshHeight();
|
||||
}
|
||||
|
||||
if (opts.ensureAtEnd) {
|
||||
this.ensureCaretAtEnd();
|
||||
}
|
||||
|
||||
if (this.capabilities.isIpadOS || this.site.mobileView) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.focusTextArea();
|
||||
});
|
||||
}
|
||||
|
||||
ensureCaretAtEnd() {
|
||||
schedule("afterRender", () => {
|
||||
this._textarea.setSelectionRange(
|
||||
this._textarea.value.length,
|
||||
this._textarea.value.length
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
refreshHeight() {
|
||||
schedule("afterRender", () => {
|
||||
// this is a quirk which forces us to `auto` first or textarea
|
||||
// won't resize
|
||||
this._textarea.style.height = "auto";
|
||||
|
||||
// +1 is to workaround a rounding error visible on electron
|
||||
// causing scrollbars to show when they shouldn’t
|
||||
this._textarea.style.height = this._textarea.scrollHeight + 1 + "px";
|
||||
});
|
||||
}
|
||||
}
|
|
@ -8,6 +8,7 @@ import slugifyChannel from "discourse/plugins/chat/discourse/lib/slugify-channel
|
|||
import ChatThreadsManager from "discourse/plugins/chat/discourse/lib/chat-threads-manager";
|
||||
import ChatMessagesManager from "discourse/plugins/chat/discourse/lib/chat-messages-manager";
|
||||
import { getOwner } from "discourse-common/lib/get-owner";
|
||||
import guid from "pretty-text/guid";
|
||||
|
||||
export const CHATABLE_TYPES = {
|
||||
directMessageChannel: "DirectMessage",
|
||||
|
@ -78,6 +79,10 @@ export default class ChatChannel extends RestModel {
|
|||
this.messagesManager.messages = messages;
|
||||
}
|
||||
|
||||
get canLoadMoreFuture() {
|
||||
return this.messagesManager.canLoadMoreFuture;
|
||||
}
|
||||
|
||||
get escapedTitle() {
|
||||
return escapeExpression(this.title);
|
||||
}
|
||||
|
@ -150,6 +155,27 @@ export default class ChatChannel extends RestModel {
|
|||
this.channelMessageBusLastId = details.channel_message_bus_last_id;
|
||||
}
|
||||
|
||||
stageMessage(message) {
|
||||
message.id = guid();
|
||||
message.staged = true;
|
||||
message.draft = false;
|
||||
message.createdAt ??= moment.utc().format();
|
||||
message.cook();
|
||||
|
||||
if (message.inReplyTo) {
|
||||
if (!message.inReplyTo.threadId) {
|
||||
message.inReplyTo.threadId = guid();
|
||||
message.inReplyTo.threadReplyCount = 1;
|
||||
}
|
||||
|
||||
if (!this.threadingEnabled) {
|
||||
this.messagesManager.addMessages([message]);
|
||||
}
|
||||
} else {
|
||||
this.messagesManager.addMessages([message]);
|
||||
}
|
||||
}
|
||||
|
||||
canModifyMessages(user) {
|
||||
if (user.staff) {
|
||||
return !STAFF_READONLY_STATUSES.includes(this.status);
|
||||
|
|
|
@ -1,62 +0,0 @@
|
|||
import { tracked } from "@glimmer/tracking";
|
||||
|
||||
export default class ChatMessageDraft {
|
||||
static create(args = {}) {
|
||||
return new ChatMessageDraft(args ?? {});
|
||||
}
|
||||
|
||||
@tracked uploads;
|
||||
@tracked message;
|
||||
@tracked _replyToMsg;
|
||||
|
||||
constructor(args = {}) {
|
||||
this.message = args.message ?? "";
|
||||
this.uploads = args.uploads ?? [];
|
||||
this.replyToMsg = args.replyToMsg;
|
||||
}
|
||||
|
||||
get replyToMsg() {
|
||||
return this._replyToMsg;
|
||||
}
|
||||
|
||||
set replyToMsg(message) {
|
||||
this._replyToMsg = message
|
||||
? {
|
||||
id: message.id,
|
||||
excerpt: message.excerpt,
|
||||
user: {
|
||||
id: message.user.id,
|
||||
name: message.user.name,
|
||||
avatar_template: message.user.avatar_template,
|
||||
username: message.user.username,
|
||||
},
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
if (
|
||||
this.message?.length === 0 &&
|
||||
this.uploads?.length === 0 &&
|
||||
!this.replyToMsg
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = {};
|
||||
|
||||
if (this.uploads?.length > 0) {
|
||||
data.uploads = this.uploads;
|
||||
}
|
||||
|
||||
if (this.message?.length > 0) {
|
||||
data.message = this.message;
|
||||
}
|
||||
|
||||
if (this.replyToMsg) {
|
||||
data.replyToMsg = this.replyToMsg;
|
||||
}
|
||||
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
}
|
|
@ -4,7 +4,9 @@ import { TrackedArray, TrackedObject } from "@ember-compat/tracked-built-ins";
|
|||
import ChatMessageReaction from "discourse/plugins/chat/discourse/models/chat-message-reaction";
|
||||
import Bookmark from "discourse/models/bookmark";
|
||||
import I18n from "I18n";
|
||||
import guid from "pretty-text/guid";
|
||||
import { generateCookFunction } from "discourse/lib/text";
|
||||
import simpleCategoryHashMentionTransform from "discourse/plugins/chat/discourse/lib/simple-category-hash-mention-transform";
|
||||
import { getOwner } from "discourse-common/lib/get-owner";
|
||||
|
||||
export default class ChatMessage {
|
||||
static cookFunction = null;
|
||||
|
@ -13,10 +15,9 @@ export default class ChatMessage {
|
|||
return new ChatMessage(channel, args);
|
||||
}
|
||||
|
||||
static createStagedMessage(channel, args = {}) {
|
||||
args.id = guid();
|
||||
args.staged = true;
|
||||
return new ChatMessage(channel, args);
|
||||
static createDraftMessage(channel, args = {}) {
|
||||
args.draft = true;
|
||||
return ChatMessage.create(channel, args);
|
||||
}
|
||||
|
||||
@tracked id;
|
||||
|
@ -24,6 +25,7 @@ export default class ChatMessage {
|
|||
@tracked selected;
|
||||
@tracked channel;
|
||||
@tracked staged = false;
|
||||
@tracked draft = false;
|
||||
@tracked channelId;
|
||||
@tracked createdAt;
|
||||
@tracked deletedAt;
|
||||
|
@ -69,11 +71,20 @@ export default class ChatMessage {
|
|||
this.reviewableId = args.reviewableId || args.reviewable_id;
|
||||
this.userFlagStatus = args.userFlagStatus || args.user_flag_status;
|
||||
this.inReplyTo =
|
||||
args.inReplyTo || args.in_reply_to
|
||||
? ChatMessage.create(channel, args.in_reply_to)
|
||||
: null;
|
||||
this.message = args.message;
|
||||
this.cooked = args.cooked || ChatMessage.cookFunction(this.message);
|
||||
args.inReplyTo ||
|
||||
(args.in_reply_to || args.replyToMsg
|
||||
? ChatMessage.create(channel, args.in_reply_to || args.replyToMsg)
|
||||
: null);
|
||||
this.draft = args.draft;
|
||||
this.message = args.message || "";
|
||||
|
||||
if (args.cooked) {
|
||||
this.cooked = args.cooked;
|
||||
} else {
|
||||
this.cooked = "";
|
||||
this.cook();
|
||||
}
|
||||
|
||||
this.reactions = this.#initChatMessageReactionModel(
|
||||
args.id,
|
||||
args.reactions
|
||||
|
@ -83,6 +94,38 @@ export default class ChatMessage {
|
|||
this.bookmark = args.bookmark ? Bookmark.create(args.bookmark) : null;
|
||||
}
|
||||
|
||||
cook() {
|
||||
const site = getOwner(this).lookup("service:site");
|
||||
|
||||
const markdownOptions = {
|
||||
featuresOverride:
|
||||
site.markdown_additional_options?.chat?.limited_pretty_text_features,
|
||||
markdownItRules:
|
||||
site.markdown_additional_options?.chat
|
||||
?.limited_pretty_text_markdown_rules,
|
||||
hashtagTypesInPriorityOrder:
|
||||
site.hashtag_configurations?.["chat-composer"],
|
||||
hashtagIcons: site.hashtag_icons,
|
||||
};
|
||||
|
||||
if (ChatMessage.cookFunction) {
|
||||
this.cooked = ChatMessage.cookFunction(this.message);
|
||||
this.incrementVersion();
|
||||
} else {
|
||||
generateCookFunction(markdownOptions).then((cookFunction) => {
|
||||
ChatMessage.cookFunction = (raw) => {
|
||||
return simpleCategoryHashMentionTransform(
|
||||
cookFunction(raw),
|
||||
site.categories
|
||||
);
|
||||
};
|
||||
|
||||
this.cooked = ChatMessage.cookFunction(this.message);
|
||||
this.incrementVersion();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get threadRouteModels() {
|
||||
return [...this.channel.routeModels, this.threadId];
|
||||
}
|
||||
|
@ -134,6 +177,41 @@ export default class ChatMessage {
|
|||
this.version++;
|
||||
}
|
||||
|
||||
toJSONDraft() {
|
||||
if (
|
||||
this.message?.length === 0 &&
|
||||
this.uploads?.length === 0 &&
|
||||
!this.inReplyTo
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = {};
|
||||
|
||||
if (this.uploads?.length > 0) {
|
||||
data.uploads = this.uploads;
|
||||
}
|
||||
|
||||
if (this.message?.length > 0) {
|
||||
data.message = this.message;
|
||||
}
|
||||
|
||||
if (this.inReplyTo) {
|
||||
data.replyToMsg = {
|
||||
id: this.inReplyTo.id,
|
||||
excerpt: this.inReplyTo.excerpt,
|
||||
user: {
|
||||
id: this.inReplyTo.user.id,
|
||||
name: this.inReplyTo.user.name,
|
||||
avatar_template: this.inReplyTo.user.avatar_template,
|
||||
username: this.inReplyTo.user.username,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
|
||||
react(emoji, action, actor, currentUserId) {
|
||||
const selfReaction = actor.id === currentUserId;
|
||||
const existingReaction = this.reactions.find(
|
||||
|
|
|
@ -3,6 +3,7 @@ import ChatMessagesManager from "discourse/plugins/chat/discourse/lib/chat-messa
|
|||
import User from "discourse/models/user";
|
||||
import { escapeExpression } from "discourse/lib/utilities";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import guid from "pretty-text/guid";
|
||||
|
||||
export const THREAD_STATUSES = {
|
||||
open: "open",
|
||||
|
@ -28,6 +29,16 @@ export default class ChatThread {
|
|||
this.originalMessage.user = this.originalMessageUser;
|
||||
}
|
||||
|
||||
stageMessage(message) {
|
||||
message.id = guid();
|
||||
message.staged = true;
|
||||
message.draft = false;
|
||||
message.createdAt ??= moment.utc().format();
|
||||
message.cook();
|
||||
|
||||
this.messagesManager.addMessages([message]);
|
||||
}
|
||||
|
||||
get messages() {
|
||||
return this.messagesManager.messages;
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ export default class ResizableNode extends Modifier {
|
|||
element = null;
|
||||
resizerSelector = null;
|
||||
didResizeContainer = null;
|
||||
options = null;
|
||||
|
||||
_originalWidth = 0;
|
||||
_originalHeight = 0;
|
||||
|
@ -22,10 +23,14 @@ export default class ResizableNode extends Modifier {
|
|||
registerDestructor(this, (instance) => instance.cleanup());
|
||||
}
|
||||
|
||||
modify(element, [resizerSelector, didResizeContainer]) {
|
||||
modify(element, [resizerSelector, didResizeContainer, options = {}]) {
|
||||
this.resizerSelector = resizerSelector;
|
||||
this.element = element;
|
||||
this.didResizeContainer = didResizeContainer;
|
||||
this.options = Object.assign(
|
||||
{ vertical: true, horizontal: true, position: true, mutate: true },
|
||||
options
|
||||
);
|
||||
|
||||
this.element
|
||||
.querySelector(this.resizerSelector)
|
||||
|
@ -97,21 +102,29 @@ export default class ResizableNode extends Modifier {
|
|||
|
||||
const newStyle = {};
|
||||
|
||||
if (width > MINIMUM_SIZE) {
|
||||
if (this.options.horizontal && width > MINIMUM_SIZE) {
|
||||
newStyle.width = width + "px";
|
||||
|
||||
if (this.options.position) {
|
||||
newStyle.left =
|
||||
Math.ceil(this._originalX + (event.pageX - this._originalMouseX)) +
|
||||
"px";
|
||||
}
|
||||
}
|
||||
|
||||
if (height > MINIMUM_SIZE) {
|
||||
if (this.options.vertical && height > MINIMUM_SIZE) {
|
||||
newStyle.height = height + "px";
|
||||
|
||||
if (this.options.position) {
|
||||
newStyle.top =
|
||||
Math.ceil(this._originalY + (event.pageY - this._originalMouseY)) +
|
||||
"px";
|
||||
}
|
||||
}
|
||||
|
||||
if (this.options.mutate) {
|
||||
Object.assign(this.element.style, newStyle);
|
||||
}
|
||||
|
||||
this.didResizeContainer?.(this.element, { width, height });
|
||||
}
|
||||
|
|
|
@ -299,7 +299,7 @@ export default class ChatApi extends Service {
|
|||
/**
|
||||
* Saves a draft for the channel, which includes message contents and uploads.
|
||||
* @param {number} channelId - The ID of the channel.
|
||||
* @param {object} data - The draft data, see ChatMessageDraft.toJSON() for more details.
|
||||
* @param {object} data - The draft data, see ChatMessage.toJSONDraft() for more details.
|
||||
* @returns {Promise}
|
||||
*/
|
||||
saveDraft(channelId, data) {
|
||||
|
|
|
@ -1,132 +1,60 @@
|
|||
import { debounce } from "discourse-common/utils/decorators";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import Service, { inject as service } from "@ember/service";
|
||||
import { action } from "@ember/object";
|
||||
import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
|
||||
|
||||
export default class ChatChannelComposer extends Service {
|
||||
@service chat;
|
||||
@service chatApi;
|
||||
@service chatComposerPresenceManager;
|
||||
@service currentUser;
|
||||
|
||||
@tracked editingMessage = null;
|
||||
@tracked replyToMsg = null;
|
||||
@tracked linkedComponent = null;
|
||||
@tracked _message;
|
||||
|
||||
reset() {
|
||||
this.editingMessage = null;
|
||||
this.replyToMsg = null;
|
||||
}
|
||||
|
||||
get model() {
|
||||
return this.chat.activeChannel;
|
||||
}
|
||||
|
||||
setReplyTo(messageOrId) {
|
||||
if (messageOrId) {
|
||||
this.cancelEditing();
|
||||
|
||||
const message =
|
||||
typeof messageOrId === "number"
|
||||
? this.model.messagesManager.findMessage(messageOrId)
|
||||
: messageOrId;
|
||||
this.replyToMsg = message;
|
||||
this.focusComposer();
|
||||
} else {
|
||||
this.replyToMsg = null;
|
||||
}
|
||||
|
||||
this.onComposerValueChange({ replyToMsg: this.replyToMsg });
|
||||
}
|
||||
|
||||
editButtonClicked(messageId) {
|
||||
const message = this.model.messagesManager.findMessage(messageId);
|
||||
this.editingMessage = message;
|
||||
|
||||
// TODO (martin) Move scrollToLatestMessage to live panel.
|
||||
// this.scrollToLatestMessage();
|
||||
|
||||
this.focusComposer();
|
||||
}
|
||||
|
||||
onComposerValueChange({
|
||||
value,
|
||||
uploads,
|
||||
replyToMsg,
|
||||
inProgressUploadsCount,
|
||||
}) {
|
||||
if (!this.model) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.editingMessage && !this.model.isDraft) {
|
||||
if (typeof value !== "undefined" && this.model.draft) {
|
||||
this.model.draft.message = value;
|
||||
}
|
||||
|
||||
// only save the uploads to the draft if we are not still uploading other
|
||||
// ones, otherwise we get into a cycle where we pass the draft uploads as
|
||||
// existingUploads back to the upload component and cause in progress ones
|
||||
// to be cancelled
|
||||
if (
|
||||
typeof uploads !== "undefined" &&
|
||||
inProgressUploadsCount !== "undefined" &&
|
||||
inProgressUploadsCount === 0 &&
|
||||
this.model.draft
|
||||
) {
|
||||
this.model.draft.uploads = uploads;
|
||||
}
|
||||
|
||||
if (typeof replyToMsg !== "undefined" && this.model.draft) {
|
||||
this.model.draft.replyToMsg = replyToMsg;
|
||||
@action
|
||||
cancel() {
|
||||
if (this.message.editing) {
|
||||
this.reset();
|
||||
} else if (this.message.inReplyTo) {
|
||||
this.message.inReplyTo = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.model.isDraft) {
|
||||
this.#reportReplyingPresence(value);
|
||||
@action
|
||||
reset(channel) {
|
||||
this.message = ChatMessage.createDraftMessage(channel, {
|
||||
user: this.currentUser,
|
||||
});
|
||||
}
|
||||
|
||||
this._persistDraft();
|
||||
@action
|
||||
clear() {
|
||||
this.message.message = "";
|
||||
}
|
||||
|
||||
cancelEditing() {
|
||||
this.editingMessage = null;
|
||||
@action
|
||||
editMessage(message) {
|
||||
this.chat.activeMessage = null;
|
||||
message.editing = true;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
registerFocusHandler(handlerFn) {
|
||||
this.focusHandler = handlerFn;
|
||||
@action
|
||||
onCancelEditing() {
|
||||
this.reset();
|
||||
}
|
||||
|
||||
focusComposer() {
|
||||
this.focusHandler();
|
||||
@action
|
||||
replyTo(message) {
|
||||
this.chat.activeMessage = null;
|
||||
this.message.inReplyTo = message;
|
||||
}
|
||||
|
||||
#reportReplyingPresence(composerValue) {
|
||||
if (this.#componentDeleted) {
|
||||
return;
|
||||
get message() {
|
||||
return this._message;
|
||||
}
|
||||
|
||||
if (this.model.isDraft) {
|
||||
return;
|
||||
}
|
||||
|
||||
const replying = !this.editingMessage && !!composerValue;
|
||||
this.chatComposerPresenceManager.notifyState(this.model.id, replying);
|
||||
}
|
||||
|
||||
@debounce(2000)
|
||||
_persistDraft() {
|
||||
if (this.#componentDeleted || !this.model) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.model.draft) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.chatApi.saveDraft(this.model.id, this.model.draft.toJSON());
|
||||
}
|
||||
|
||||
get #componentDeleted() {
|
||||
// note I didn't set this in the new version, not sure yet what to do with it
|
||||
// return this.linkedComponent._selfDeleted;
|
||||
set message(message) {
|
||||
this._message = message;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { tracked } from "@glimmer/tracking";
|
||||
import { action } from "@ember/object";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import Service, { inject as service } from "@ember/service";
|
||||
|
||||
export default class ChatChannelPane extends Service {
|
||||
|
@ -13,7 +12,7 @@ export default class ChatChannelPane extends Service {
|
|||
@tracked reacting = false;
|
||||
@tracked selectingMessages = false;
|
||||
@tracked lastSelectedMessage = null;
|
||||
@tracked sendingLoading = false;
|
||||
@tracked sending = false;
|
||||
|
||||
get selectedMessageIds() {
|
||||
return this.chat.activeChannel?.selectedMessages?.mapBy("id") || [];
|
||||
|
@ -23,6 +22,10 @@ export default class ChatChannelPane extends Service {
|
|||
return this.chatChannelComposer;
|
||||
}
|
||||
|
||||
get channel() {
|
||||
return this.chat.activeChannel;
|
||||
}
|
||||
|
||||
@action
|
||||
cancelSelecting(selectedMessages) {
|
||||
this.selectingMessages = false;
|
||||
|
@ -37,55 +40,19 @@ export default class ChatChannelPane extends Service {
|
|||
this.selectingMessages = true;
|
||||
}
|
||||
|
||||
@action
|
||||
editMessage(newContent, uploads) {
|
||||
this.sendingLoading = true;
|
||||
let data = {
|
||||
new_message: newContent,
|
||||
upload_ids: (uploads || []).map((upload) => upload.id),
|
||||
};
|
||||
return this.chatApi
|
||||
.editMessage(
|
||||
this.composerService.editingMessage.channelId,
|
||||
this.composerService.editingMessage.id,
|
||||
data
|
||||
)
|
||||
.then(() => {
|
||||
this.resetAfterSend();
|
||||
})
|
||||
.catch(popupAjaxError)
|
||||
.finally(() => {
|
||||
if (this._selfDeleted) {
|
||||
return;
|
||||
}
|
||||
this.sendingLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
resetAfterSend() {
|
||||
const channelId = this.composerService.editingMessage?.channelId;
|
||||
if (channelId) {
|
||||
this.chatComposerPresenceManager.notifyState(channelId, false);
|
||||
}
|
||||
|
||||
this.composerService.reset();
|
||||
}
|
||||
|
||||
@action
|
||||
editLastMessageRequested() {
|
||||
const lastUserMessage = this.chat.activeChannel.messages.findLast(
|
||||
get lastCurrentUserMessage() {
|
||||
const lastCurrentUserMessage = this.chat.activeChannel.messages.findLast(
|
||||
(message) => message.user.id === this.currentUser.id
|
||||
);
|
||||
|
||||
if (!lastUserMessage) {
|
||||
if (!lastCurrentUserMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastUserMessage.staged || lastUserMessage.error) {
|
||||
if (lastCurrentUserMessage.staged || lastCurrentUserMessage.error) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.composerService.editingMessage = lastUserMessage;
|
||||
this.composerService.focusComposer();
|
||||
return lastCurrentUserMessage;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,15 +1,13 @@
|
|||
import ChatChannelComposer from "./chat-channel-composer";
|
||||
import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
|
||||
import { action } from "@ember/object";
|
||||
|
||||
export default class extends ChatChannelComposer {
|
||||
get model() {
|
||||
return this.chat.activeChannel.activeThread;
|
||||
}
|
||||
|
||||
_persistDraft() {
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug(
|
||||
"Drafts are unsupported for chat threads at this point in time"
|
||||
);
|
||||
return;
|
||||
@action
|
||||
reset(channel) {
|
||||
this.message = ChatMessage.createDraftMessage(channel, {
|
||||
user: this.currentUser,
|
||||
thread_id: channel.activeThread.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,4 +11,21 @@ export default class ChatChannelThreadPane extends ChatChannelPane {
|
|||
get composerService() {
|
||||
return this.chatChannelThreadComposer;
|
||||
}
|
||||
|
||||
get lastCurrentUserMessage() {
|
||||
const lastCurrentUserMessage =
|
||||
this.chat.activeChannel.activeThread.messages.findLast(
|
||||
(message) => message.user.id === this.currentUser.id
|
||||
);
|
||||
|
||||
if (!lastCurrentUserMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastCurrentUserMessage.staged || lastCurrentUserMessage.error) {
|
||||
return;
|
||||
}
|
||||
|
||||
return lastCurrentUserMessage;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
import Service from "@ember/service";
|
||||
import KeyValueStore from "discourse/lib/key-value-store";
|
||||
|
||||
export default class ChatSidePanelSize extends Service {
|
||||
STORE_NAMESPACE = "discourse_chat_side_panel_size_";
|
||||
MIN_WIDTH = 250;
|
||||
|
||||
store = new KeyValueStore(this.STORE_NAMESPACE);
|
||||
|
||||
get width() {
|
||||
return this.store.getObject("width") || this.MIN_WIDTH;
|
||||
}
|
||||
|
||||
set width(width) {
|
||||
this.store.setObject({
|
||||
key: "width",
|
||||
value: this.#min(width, this.MIN_WIDTH),
|
||||
});
|
||||
}
|
||||
|
||||
#min(number, min) {
|
||||
return Math.max(number, min);
|
||||
}
|
||||
}
|
|
@ -8,7 +8,7 @@ import { cancel, next } from "@ember/runloop";
|
|||
import { and } from "@ember/object/computed";
|
||||
import { computed } from "@ember/object";
|
||||
import discourseLater from "discourse-common/lib/later";
|
||||
import ChatMessageDraft from "discourse/plugins/chat/discourse/models/chat-message-draft";
|
||||
import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
|
||||
|
||||
const CHAT_ONLINE_OPTIONS = {
|
||||
userUnseenTime: 300000, // 5 minutes seconds with no interaction
|
||||
|
@ -126,8 +126,15 @@ export default class Chat extends Service {
|
|||
const storedDraft = this.currentUser.chat_drafts.find(
|
||||
(draft) => draft.channel_id === channel.id
|
||||
);
|
||||
channel.draft = ChatMessageDraft.create(
|
||||
storedDraft ? JSON.parse(storedDraft.data) : null
|
||||
|
||||
channel.draft = ChatMessage.createDraftMessage(
|
||||
channel,
|
||||
Object.assign(
|
||||
{
|
||||
user: this.currentUser,
|
||||
},
|
||||
storedDraft ? JSON.parse(storedDraft.data) : {}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -145,13 +145,9 @@ $float-height: 530px;
|
|||
word-wrap: break-word;
|
||||
white-space: normal;
|
||||
position: relative;
|
||||
will-change: transform;
|
||||
transform: translateZ(0);
|
||||
|
||||
.chat-message-container {
|
||||
display: grid;
|
||||
will-change: transform;
|
||||
transform: translateZ(0);
|
||||
|
||||
&.selecting-messages {
|
||||
grid-template-columns: 1.5em 1fr;
|
||||
|
@ -256,7 +252,7 @@ $float-height: 530px;
|
|||
}
|
||||
}
|
||||
|
||||
.chat-live-pane {
|
||||
.chat-channel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
|
@ -432,9 +428,8 @@ body.has-full-page-chat {
|
|||
}
|
||||
}
|
||||
|
||||
.chat-live-pane,
|
||||
.chat-messages-scroll,
|
||||
.chat-live-pane {
|
||||
.chat-channel {
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
}
|
||||
|
@ -629,7 +624,7 @@ html.has-full-page-chat {
|
|||
}
|
||||
|
||||
.full-page-chat,
|
||||
.chat-live-pane,
|
||||
.chat-channel,
|
||||
#main-outlet {
|
||||
// allows containers to shrink to fit
|
||||
min-height: 0;
|
||||
|
|
|
@ -0,0 +1,127 @@
|
|||
.chat-channel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 1px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
grid-area: main;
|
||||
width: 100%;
|
||||
min-width: 300px;
|
||||
|
||||
.open-drawer-btn {
|
||||
color: var(--primary-low-mid);
|
||||
|
||||
&:visited {
|
||||
color: var(--primary-low-mid);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
> * {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-messages-scroll {
|
||||
flex-grow: 1;
|
||||
overflow-y: scroll;
|
||||
overscroll-behavior: contain;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
z-index: 1;
|
||||
margin: 0 1px 0 0;
|
||||
will-change: transform;
|
||||
@include chat-scrollbar();
|
||||
|
||||
.join-channel-btn.in-float {
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
left: 50%;
|
||||
top: 10px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.all-loaded-message {
|
||||
text-align: center;
|
||||
color: var(--primary-medium);
|
||||
font-size: var(--font-down-1);
|
||||
padding: 0.5em 0.25em 0.25em;
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-stick-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 0 1rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chat-scroll-to-bottom {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
flex-direction: column;
|
||||
bottom: -25px;
|
||||
background: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.25s ease, transform 0.5s ease;
|
||||
transform: scale(0.1);
|
||||
padding: 0;
|
||||
|
||||
> * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus {
|
||||
background: none !important;
|
||||
}
|
||||
|
||||
&.visible {
|
||||
transform: translateY(-32px) scale(1);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&__text {
|
||||
color: var(--secondary);
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: var(--primary-medium);
|
||||
border-radius: 3px;
|
||||
text-align: center;
|
||||
font-size: var(--font-down-1);
|
||||
bottom: 40px;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
&__arrow {
|
||||
display: flex;
|
||||
background: var(--primary-medium);
|
||||
border-radius: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
position: relative;
|
||||
|
||||
.d-icon {
|
||||
color: var(--secondary);
|
||||
margin-left: 1px; // "fixes" the 1px svg shift
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
|
||||
.chat-scroll-to-bottom__arrow {
|
||||
.d-icon {
|
||||
color: var(--secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,30 +1,62 @@
|
|||
.chat-composer-upload {
|
||||
display: inline-flex;
|
||||
height: 50px;
|
||||
height: 64px;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--primary-low-mid);
|
||||
margin-right: 0.5em;
|
||||
position: relative;
|
||||
border-radius: 5px;
|
||||
box-sizing: border-box;
|
||||
|
||||
&--image:not(.chat-composer-upload--in-progress) {
|
||||
padding: 0;
|
||||
|
||||
.preview-img {
|
||||
height: 62px;
|
||||
width: 62px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.chat-composer-upload__remove-btn {
|
||||
visibility: visible;
|
||||
background: rgba(var(--always-black-rgb), 0.9);
|
||||
padding: 5px;
|
||||
border-radius: 100%;
|
||||
font-size: var(--font-down-2);
|
||||
}
|
||||
}
|
||||
|
||||
&__remove-btn {
|
||||
border: 1px solid var(--primary-medium);
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.preview {
|
||||
width: 50px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 1em 0 0;
|
||||
border-radius: 8px;
|
||||
margin: 0;
|
||||
|
||||
.d-icon {
|
||||
font-size: var(--font-up-6);
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.preview-img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-position: center;
|
||||
object-fit: cover;
|
||||
border-radius: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
max-width: 100%;
|
||||
|
||||
.chat-composer-uploads-container {
|
||||
padding: 0.5rem 10px;
|
||||
padding: 0.5rem 0.25rem;
|
||||
display: flex;
|
||||
white-space: nowrap;
|
||||
overflow-x: auto;
|
||||
|
|
|
@ -1,8 +1,13 @@
|
|||
.chat-composer-container {
|
||||
.chat-composer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&__wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 3;
|
||||
background-color: var(--secondary);
|
||||
margin-top: 0.1rem;
|
||||
|
||||
#chat-full-page-uploader,
|
||||
#chat-widget-uploader {
|
||||
|
@ -12,49 +17,88 @@
|
|||
.drop-a-file {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-composer {
|
||||
&__outer-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-inline: 0.25rem;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__inner-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
flex-direction: row;
|
||||
border: 1px solid var(--primary-low-mid);
|
||||
border-radius: 5px;
|
||||
background-color: var(--secondary);
|
||||
border: 1px solid var(--primary-low-mid);
|
||||
border-radius: 5px;
|
||||
padding: 0.15rem 0.25rem;
|
||||
margin-top: 0.5rem;
|
||||
padding-inline: 0.25rem;
|
||||
height: 42px;
|
||||
|
||||
&.is-disabled {
|
||||
background-color: var(--primary-low);
|
||||
border: 1px solid var(--primary-low-mid);
|
||||
.chat-composer--focused & {
|
||||
border-color: var(--primary-medium);
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.d-icon {
|
||||
color: var(--tertiary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
|
||||
.d-icon {
|
||||
color: var(--primary-low);
|
||||
.chat-composer--disabled & {
|
||||
background: var(--primary-low);
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:disabled) {
|
||||
&:hover,
|
||||
&__send-btn {
|
||||
border-radius: 3px;
|
||||
background: none;
|
||||
will-change: scale;
|
||||
|
||||
.chat-composer--send-enabled & {
|
||||
&:hover {
|
||||
background: none;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
background: var(--tertiary);
|
||||
background: none;
|
||||
outline: auto;
|
||||
}
|
||||
|
||||
.d-icon {
|
||||
color: var(--secondary);
|
||||
color: var(--tertiary) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sendingScales {
|
||||
0% {
|
||||
transform: scale(0.8);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
100% {
|
||||
transform: scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
.chat-composer--sending & {
|
||||
animation: sendingScales 1s infinite;
|
||||
}
|
||||
|
||||
.chat-composer--send-disabled & {
|
||||
cursor: default;
|
||||
opacity: 0.6 !important;
|
||||
|
||||
&:hover {
|
||||
background: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
> * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.d-icon {
|
||||
color: var(--primary) !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -82,7 +126,14 @@
|
|||
}
|
||||
}
|
||||
|
||||
.chat-composer-input {
|
||||
&__input-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__input {
|
||||
overflow-x: hidden;
|
||||
width: 100%;
|
||||
appearance: none;
|
||||
|
@ -97,14 +148,21 @@
|
|||
|
||||
@include chat-scrollbar();
|
||||
|
||||
&[disabled] {
|
||||
background: none;
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:active {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:placeholder-shown,
|
||||
&::placeholder {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@include chat-scrollbar();
|
||||
}
|
||||
|
||||
&__unreliable-network {
|
||||
|
|
|
@ -37,7 +37,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
.chat-composer-container {
|
||||
.chat-composer__wrapper {
|
||||
padding-bottom: 0.5em;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -94,7 +94,7 @@ html.rtl {
|
|||
height: auto !important;
|
||||
}
|
||||
|
||||
.chat-live-pane {
|
||||
.chat-channel {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
.chat-side-panel-resizer {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -3px;
|
||||
width: 5px;
|
||||
position: absolute;
|
||||
z-index: z("max");
|
||||
transition: background-color 0.15s 0.15s;
|
||||
background-color: transparent;
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
cursor: col-resize;
|
||||
background: var(--tertiary);
|
||||
}
|
||||
}
|
|
@ -1,13 +1,9 @@
|
|||
#main-chat-outlet.chat-view {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
grid-template-rows: 100%;
|
||||
grid-template-areas: "main threads";
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
&.has-side-panel-expanded {
|
||||
grid-template-columns: 3fr 2fr;
|
||||
}
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
|
||||
.chat-side-panel {
|
||||
|
@ -15,6 +11,8 @@
|
|||
min-height: 100%;
|
||||
box-sizing: border-box;
|
||||
border-left: 1px solid var(--primary-low);
|
||||
position: relative;
|
||||
min-width: 250px;
|
||||
|
||||
&__list {
|
||||
flex-grow: 1;
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
|
||||
&__header {
|
||||
height: var(--chat-header-offset);
|
||||
|
@ -27,4 +28,8 @@
|
|||
flex-direction: column-reverse;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.chat-composer {
|
||||
padding-bottom: 28px;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,77 @@
|
|||
.chat-upload-drop-zone {
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: z("max");
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
background: rgba(var(--always-black-rgb), 0.85);
|
||||
|
||||
.uppy-is-drag-over & {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
&__content {
|
||||
position: relative;
|
||||
width: 50%;
|
||||
height: 50%;
|
||||
}
|
||||
|
||||
&__background {
|
||||
svg {
|
||||
transform: scale(0.1);
|
||||
transition: transform 200ms ease-in-out;
|
||||
height: 80px;
|
||||
|
||||
.uppy-is-drag-over & {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: calc(50% - 100px / 2);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&__illustration {
|
||||
svg {
|
||||
transform: scale(0.1);
|
||||
transition: transform 200ms ease-in-out;
|
||||
height: 80px;
|
||||
|
||||
.uppy-is-drag-over & {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: calc(50% - 100px / 2);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&__text {
|
||||
position: absolute;
|
||||
top: 100px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
&__title {
|
||||
width: 100%;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
font-size: var(--font-up-2);
|
||||
padding-inline: 1rem;
|
||||
color: var(--secondary-or-primary);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
@import "base-common";
|
||||
@import "sidebar-extensions";
|
||||
@import "chat-browse";
|
||||
@import "chat-channel";
|
||||
@import "chat-channel-card";
|
||||
@import "chat-channel-info";
|
||||
@import "chat-channel-preview-card";
|
||||
|
@ -35,6 +36,8 @@
|
|||
@import "chat-skeleton";
|
||||
@import "chat-tabs";
|
||||
@import "chat-thread";
|
||||
@import "chat-side-panel-resizer";
|
||||
@import "chat-upload-drop-zone";
|
||||
@import "chat-transcript";
|
||||
@import "core-extensions";
|
||||
@import "create-channel-modal";
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
&.teams-sidebar-on {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
.chat-live-pane {
|
||||
.chat-channel {
|
||||
border-radius: var(--full-page-border-radius);
|
||||
}
|
||||
}
|
||||
|
@ -19,7 +19,7 @@
|
|||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-live-pane {
|
||||
.chat-channel {
|
||||
.chat-messages-container {
|
||||
.chat-message {
|
||||
&.is-reply {
|
||||
|
@ -87,7 +87,7 @@
|
|||
border-right: 1px solid var(--primary-low);
|
||||
border-left: 1px solid var(--primary-low);
|
||||
|
||||
.chat-live-pane {
|
||||
.chat-channel {
|
||||
border-radius: unset;
|
||||
}
|
||||
}
|
||||
|
@ -112,7 +112,7 @@
|
|||
}
|
||||
|
||||
.full-page-chat.teams-sidebar-on {
|
||||
.chat-live-pane {
|
||||
.chat-channel {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
|
|
|
@ -15,7 +15,7 @@ html.has-full-page-chat {
|
|||
padding: 0;
|
||||
|
||||
.main-chat-outlet {
|
||||
.chat-live-pane {
|
||||
.chat-channel {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
|
@ -23,7 +23,7 @@ html.has-full-page-chat {
|
|||
grid-template-columns: 1fr;
|
||||
grid-template-areas: "threads";
|
||||
|
||||
.chat-live-pane {
|
||||
.chat-channel {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
@ -45,7 +45,7 @@ html.has-full-page-chat {
|
|||
}
|
||||
}
|
||||
|
||||
.chat-live-pane {
|
||||
.chat-channel {
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
.chat-composer-upload {
|
||||
&__remove-btn {
|
||||
visibility: visible;
|
||||
background: rgba(var(--always-black-rgb), 0.9);
|
||||
border-radius: 100%;
|
||||
|
||||
// overwrite ios style
|
||||
font-size: var(--font-down-2) !important;
|
||||
padding: 5px !important;
|
||||
}
|
||||
}
|
|
@ -2,7 +2,7 @@
|
|||
#skip-link,
|
||||
.d-header,
|
||||
.chat-message-actions-mobile-outlet,
|
||||
.chat-live-pane,
|
||||
.chat-channel,
|
||||
.chat-thread {
|
||||
> * {
|
||||
@include user-select(none);
|
||||
|
|
|
@ -6,3 +6,4 @@
|
|||
@import "chat-message";
|
||||
@import "chat-selection-manager";
|
||||
@import "chat-emoji-picker";
|
||||
@import "chat-composer-upload";
|
||||
|
|
|
@ -221,7 +221,8 @@ en:
|
|||
close_full_page: "Close full-screen chat"
|
||||
open_message: "Open message in chat"
|
||||
placeholder_self: "Jot something down"
|
||||
placeholder_channel: "Chat with %{channelName}"
|
||||
placeholder_channel: "Chat in %{channelName}"
|
||||
placeholder_thread: "Chat in thread"
|
||||
placeholder_users: "Chat with %{commaSeparatedNames}"
|
||||
placeholder_new_message_disallowed:
|
||||
archived: "Channel is archived, you cannot send new messages right now."
|
||||
|
@ -257,6 +258,8 @@ en:
|
|||
title: "chat"
|
||||
title_capitalized: "Chat"
|
||||
upload: "Attach a file"
|
||||
upload_to_channel: "Upload to %{title}"
|
||||
upload_to_thread: "Upload to thread"
|
||||
uploaded_files:
|
||||
one: "%{count} file"
|
||||
other: "%{count} files"
|
||||
|
@ -399,6 +402,7 @@ en:
|
|||
italic_text: "emphasized text"
|
||||
bold_text: "strong text"
|
||||
code_text: "code text"
|
||||
send: "Send"
|
||||
|
||||
quote:
|
||||
original_channel: 'Originally sent in <a href="%{channelLink}">%{channel}</a>'
|
||||
|
@ -539,6 +543,7 @@ en:
|
|||
one: "%{count} reply"
|
||||
other: "%{count} replies"
|
||||
label: Thread
|
||||
close: "Close Thread"
|
||||
threads:
|
||||
started_by: "Started by"
|
||||
open: "Open Thread"
|
||||
|
|
|
@ -10,10 +10,83 @@ RSpec.describe "Chat composer", type: :system, js: true do
|
|||
|
||||
before { chat_system_bootstrap }
|
||||
|
||||
xit "it stores draft in replies" do
|
||||
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
|
||||
|
||||
xit "it stores draft" do
|
||||
before do
|
||||
channel_1.add(current_user)
|
||||
sign_in(current_user)
|
||||
end
|
||||
|
||||
it "loads the draft" do
|
||||
chat.visit_channel(channel_1)
|
||||
|
||||
expect(find(".chat-composer__input").value).to eq("draft")
|
||||
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
|
||||
|
||||
fab!(:draft_1) do
|
||||
Chat::Draft.create!(
|
||||
chat_channel: channel_1,
|
||||
user: current_user,
|
||||
data: { message: "draft", uploads: [upload_1] }.to_json,
|
||||
)
|
||||
end
|
||||
|
||||
it "loads the draft with the upload" do
|
||||
chat.visit_channel(channel_1)
|
||||
|
||||
expect(find(".chat-composer__input").value).to eq("draft")
|
||||
expect(page).to have_selector(".chat-composer-upload--image", count: 1)
|
||||
end
|
||||
end
|
||||
|
||||
context "when replying" do
|
||||
fab!(:draft_1) do
|
||||
Chat::Draft.create!(
|
||||
chat_channel: channel_1,
|
||||
user: current_user,
|
||||
data: {
|
||||
message: "draft",
|
||||
replyToMsg: {
|
||||
id: message_1.id,
|
||||
excerpt: message_1.excerpt,
|
||||
user: {
|
||||
id: message_1.user.id,
|
||||
name: nil,
|
||||
avatar_template: message_1.user.avatar_template,
|
||||
username: message_1.user.username,
|
||||
},
|
||||
},
|
||||
}.to_json,
|
||||
)
|
||||
end
|
||||
|
||||
it "loads the draft with replied to mesage" do
|
||||
chat.visit_channel(channel_1)
|
||||
|
||||
expect(find(".chat-composer__input").value).to eq("draft")
|
||||
expect(page).to have_selector(".chat-reply__username", text: message_1.user.username)
|
||||
expect(page).to have_selector(".chat-reply__excerpt", text: message_1.excerpt)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "when replying to a message" do
|
||||
|
@ -62,17 +135,17 @@ RSpec.describe "Chat composer", type: :system, js: true do
|
|||
".chat-composer-message-details .chat-reply__username",
|
||||
text: current_user.username,
|
||||
)
|
||||
expect(find(".chat-composer-input").value).to eq(message_2.message)
|
||||
expect(find(".chat-composer__input").value).to eq(message_2.message)
|
||||
end
|
||||
|
||||
context "when pressing escape" do
|
||||
it "cancels editing" do
|
||||
chat.visit_channel(channel_1)
|
||||
channel.edit_message(message_2)
|
||||
find(".chat-composer-input").send_keys(:escape)
|
||||
find(".chat-composer__input").send_keys(:escape)
|
||||
|
||||
expect(page).to have_no_selector(".chat-composer-message-details .chat-reply__username")
|
||||
expect(find(".chat-composer-input").value).to eq("")
|
||||
expect(find(".chat-composer__input").value).to eq("")
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -83,7 +156,7 @@ RSpec.describe "Chat composer", type: :system, js: true do
|
|||
find(".cancel-message-action").click
|
||||
|
||||
expect(page).to have_no_selector(".chat-composer-message-details .chat-reply__username")
|
||||
expect(find(".chat-composer-input").value).to eq("")
|
||||
expect(find(".chat-composer__input").value).to eq("")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -100,7 +173,7 @@ RSpec.describe "Chat composer", type: :system, js: true do
|
|||
channel.click_action_button("emoji")
|
||||
find("[data-emoji='grimacing']").click(wait: 0.5)
|
||||
|
||||
expect(find(".chat-composer-input").value).to eq(":grimacing:")
|
||||
expect(find(".chat-composer__input").value).to eq(":grimacing:")
|
||||
end
|
||||
|
||||
it "removes denied emojis from insert emoji picker" do
|
||||
|
@ -123,20 +196,20 @@ RSpec.describe "Chat composer", type: :system, js: true do
|
|||
|
||||
it "adds the emoji to the composer" do
|
||||
chat.visit_channel(channel_1)
|
||||
find(".chat-composer-input").fill_in(with: ":gri")
|
||||
find(".chat-composer__input").fill_in(with: ":gri")
|
||||
find(".emoji-shortname", text: "grimacing").click
|
||||
|
||||
expect(find(".chat-composer-input").value).to eq(":grimacing: ")
|
||||
expect(find(".chat-composer__input").value).to eq(":grimacing: ")
|
||||
end
|
||||
|
||||
it "doesn't suggest denied emojis and aliases" do
|
||||
SiteSetting.emoji_deny_list = "peach|poop"
|
||||
chat.visit_channel(channel_1)
|
||||
|
||||
find(".chat-composer-input").fill_in(with: ":peac")
|
||||
find(".chat-composer__input").fill_in(with: ":peac")
|
||||
expect(page).to have_no_selector(".emoji-shortname", text: "peach")
|
||||
|
||||
find(".chat-composer-input").fill_in(with: ":hank") # alias
|
||||
find(".chat-composer__input").fill_in(with: ":hank") # alias
|
||||
expect(page).to have_no_selector(".emoji-shortname", text: "poop")
|
||||
end
|
||||
end
|
||||
|
@ -149,7 +222,7 @@ RSpec.describe "Chat composer", type: :system, js: true do
|
|||
|
||||
xit "prefills the emoji picker filter input" do
|
||||
chat.visit_channel(channel_1)
|
||||
find(".chat-composer-input").fill_in(with: ":gri")
|
||||
find(".chat-composer__input").fill_in(with: ":gri")
|
||||
|
||||
click_link(I18n.t("js.composer.more_emoji"))
|
||||
|
||||
|
@ -158,7 +231,7 @@ RSpec.describe "Chat composer", type: :system, js: true do
|
|||
|
||||
it "filters with the prefilled input" do
|
||||
chat.visit_channel(channel_1)
|
||||
find(".chat-composer-input").fill_in(with: ":fr")
|
||||
find(".chat-composer__input").fill_in(with: ":fr")
|
||||
|
||||
click_link(I18n.t("js.composer.more_emoji"))
|
||||
|
||||
|
@ -178,15 +251,15 @@ RSpec.describe "Chat composer", type: :system, js: true do
|
|||
|
||||
find("body").send_keys("b")
|
||||
|
||||
expect(find(".chat-composer-input").value).to eq("b")
|
||||
expect(find(".chat-composer__input").value).to eq("b")
|
||||
|
||||
find("body").send_keys("b")
|
||||
|
||||
expect(find(".chat-composer-input").value).to eq("bb")
|
||||
expect(find(".chat-composer__input").value).to eq("bb")
|
||||
|
||||
find("body").send_keys(:enter) # special case
|
||||
|
||||
expect(find(".chat-composer-input").value).to eq("bb")
|
||||
expect(find(".chat-composer__input").value).to eq("bb")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -22,7 +22,7 @@ RSpec.describe "Chat message", type: :system, js: true do
|
|||
channel.hover_message(message_1)
|
||||
|
||||
expect(page).to have_css(
|
||||
".chat-live-pane[data-id='#{channel_1.id}'] [data-id='#{message_1.id}'] .chat-message.is-active",
|
||||
".chat-channel[data-id='#{channel_1.id}'] [data-id='#{message_1.id}'] .chat-message.is-active",
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -11,6 +11,7 @@ RSpec.describe "Chat message - channel", type: :system, js: true do
|
|||
let(:cdp) { PageObjects::CDP.new }
|
||||
let(:chat) { PageObjects::Pages::Chat.new }
|
||||
let(:channel) { PageObjects::Pages::ChatChannel.new }
|
||||
let(:thread) { PageObjects::Pages::ChatThread.new }
|
||||
let(:message_1) { thread_1.chat_messages.first }
|
||||
|
||||
before do
|
||||
|
@ -24,12 +25,13 @@ RSpec.describe "Chat message - channel", type: :system, js: true do
|
|||
|
||||
context "when hovering a message" do
|
||||
it "adds an active class" do
|
||||
last_message = thread_1.chat_messages.last
|
||||
chat.visit_thread(thread_1)
|
||||
|
||||
channel.hover_message(message_1)
|
||||
thread.hover_message(last_message)
|
||||
|
||||
expect(page).to have_css(
|
||||
".chat-thread[data-id='#{thread_1.id}'] [data-id='#{message_1.id}'] .chat-message.is-active",
|
||||
".chat-thread[data-id='#{thread_1.id}'] [data-id='#{last_message.id}'] .chat-message.is-active",
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -81,7 +81,7 @@ RSpec.describe "Drawer", type: :system, js: true do
|
|||
channel_page.hover_message(message_1)
|
||||
expect(page).to have_css(".chat-message-actions-container")
|
||||
|
||||
find(".chat-composer-input").send_keys(:escape)
|
||||
find(".chat-composer__input").send_keys(:escape)
|
||||
|
||||
expect(page).to have_no_css(".chat-message-actions-container")
|
||||
end
|
||||
|
|
|
@ -88,8 +88,15 @@ describe "Thread indicator for chat messages", type: :system, js: true do
|
|||
channel_page.reply_to(message_without_thread)
|
||||
channel_page.fill_composer("this is a reply to make a new thread")
|
||||
channel_page.click_send_message
|
||||
|
||||
expect(channel_page).to have_thread_indicator(message_without_thread)
|
||||
|
||||
new_thread = nil
|
||||
try_until_success(timeout: 5) do
|
||||
new_thread = message_without_thread.reload.thread
|
||||
expect(new_thread).to be_present
|
||||
end
|
||||
|
||||
expect(page).not_to have_css(channel_page.message_by_id_selector(new_thread.replies.first))
|
||||
end
|
||||
|
||||
|
|
|
@ -19,7 +19,7 @@ module PageObjects
|
|||
|
||||
def visit_channel(channel, mobile: false)
|
||||
visit(channel.url + (mobile ? "?mobile_view=1" : ""))
|
||||
has_no_css?(".not-loaded-once")
|
||||
has_no_css?(".chat-channel--not-loaded-once")
|
||||
has_no_css?(".chat-skeleton")
|
||||
end
|
||||
|
||||
|
|
|
@ -4,25 +4,25 @@ module PageObjects
|
|||
module Pages
|
||||
class ChatChannel < PageObjects::Pages::Base
|
||||
def type_in_composer(input)
|
||||
find(".chat-composer-input--channel").click # makes helper more reliable by ensuring focus is not lost
|
||||
find(".chat-composer-input--channel").send_keys(input)
|
||||
find(".chat-channel .chat-composer__input").click # makes helper more reliable by ensuring focus is not lost
|
||||
find(".chat-channel .chat-composer__input").send_keys(input)
|
||||
end
|
||||
|
||||
def fill_composer(input)
|
||||
find(".chat-composer-input--channel").click # makes helper more reliable by ensuring focus is not lost
|
||||
find(".chat-composer-input--channel").fill_in(with: input)
|
||||
find(".chat-channel .chat-composer__input").click # makes helper more reliable by ensuring focus is not lost
|
||||
find(".chat-channel .chat-composer__input").fill_in(with: input)
|
||||
end
|
||||
|
||||
def click_composer
|
||||
find(".chat-composer-input--channel").click # ensures autocomplete is closed and not masking anything
|
||||
find(".chat-channel .chat-composer__input").click # ensures autocomplete is closed and not masking anything
|
||||
end
|
||||
|
||||
def click_send_message
|
||||
find(".chat-composer .send-btn:enabled").click
|
||||
find(".chat-composer--send-enabled .chat-composer__send-btn").click
|
||||
end
|
||||
|
||||
def message_by_id_selector(id)
|
||||
".chat-live-pane .chat-messages-container .chat-message-container[data-id=\"#{id}\"]"
|
||||
".chat-channel .chat-messages-container .chat-message-container[data-id=\"#{id}\"]"
|
||||
end
|
||||
|
||||
def message_by_id(id)
|
||||
|
|
|
@ -24,17 +24,17 @@ module PageObjects
|
|||
end
|
||||
|
||||
def type_in_composer(input)
|
||||
find(".chat-composer-input--thread").click # makes helper more reliable by ensuring focus is not lost
|
||||
find(".chat-composer-input--thread").send_keys(input)
|
||||
find(".chat-thread .chat-composer__input").click # makes helper more reliable by ensuring focus is not lost
|
||||
find(".chat-thread .chat-composer__input").send_keys(input)
|
||||
end
|
||||
|
||||
def fill_composer(input)
|
||||
find(".chat-composer-input--thread").click # makes helper more reliable by ensuring focus is not lost
|
||||
find(".chat-composer-input--thread").fill_in(with: input)
|
||||
find(".chat-thread .chat-composer__input").click # makes helper more reliable by ensuring focus is not lost
|
||||
find(".chat-thread .chat-composer__input").fill_in(with: input)
|
||||
end
|
||||
|
||||
def click_composer
|
||||
find(".chat-composer-input--thread").click # ensures autocomplete is closed and not masking anything
|
||||
find(".chat-thread .chat-composer__input").click # ensures autocomplete is closed and not masking anything
|
||||
end
|
||||
|
||||
def send_message(id, text = nil)
|
||||
|
@ -45,7 +45,9 @@ module PageObjects
|
|||
end
|
||||
|
||||
def click_send_message(id)
|
||||
find(thread_selector_by_id(id)).find(".chat-composer .send-btn:enabled").click
|
||||
find(thread_selector_by_id(id)).find(
|
||||
".chat-composer--send-enabled .chat-composer__send-btn",
|
||||
).click
|
||||
end
|
||||
|
||||
def has_message?(thread_id, text: nil, id: nil)
|
||||
|
@ -73,6 +75,18 @@ module PageObjects
|
|||
)
|
||||
end
|
||||
end
|
||||
|
||||
def hover_message(message)
|
||||
message_by_id(message.id).hover
|
||||
end
|
||||
|
||||
def message_by_id(id)
|
||||
find(message_by_id_selector(id))
|
||||
end
|
||||
|
||||
def message_by_id_selector(id)
|
||||
".chat-thread .chat-messages-container .chat-message-container[data-id=\"#{id}\"]"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -36,7 +36,7 @@ module PageObjects
|
|||
end
|
||||
|
||||
def has_open_channel?(channel)
|
||||
has_css?("#{VISIBLE_DRAWER} .chat-live-pane[data-id='#{channel.id}']")
|
||||
has_css?("#{VISIBLE_DRAWER} .chat-channel[data-id='#{channel.id}']")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -23,7 +23,7 @@ RSpec.describe "Shortcuts | chat composer", type: :system, js: true do
|
|||
it "adds bold text" do
|
||||
chat.visit_channel(channel_1)
|
||||
|
||||
composer = find(".chat-composer-input")
|
||||
composer = find(".chat-composer__input")
|
||||
composer.send_keys([key_modifier, "b"])
|
||||
|
||||
expect(composer.value).to eq("**strong text**")
|
||||
|
@ -34,7 +34,7 @@ RSpec.describe "Shortcuts | chat composer", type: :system, js: true do
|
|||
it "adds italic text" do
|
||||
chat.visit_channel(channel_1)
|
||||
|
||||
composer = find(".chat-composer-input")
|
||||
composer = find(".chat-composer__input")
|
||||
composer.send_keys([key_modifier, "i"])
|
||||
|
||||
expect(composer.value).to eq("_emphasized text_")
|
||||
|
@ -45,7 +45,7 @@ RSpec.describe "Shortcuts | chat composer", type: :system, js: true do
|
|||
it "adds preformatted text" do
|
||||
chat.visit_channel(channel_1)
|
||||
|
||||
composer = find(".chat-composer-input")
|
||||
composer = find(".chat-composer__input")
|
||||
composer.send_keys([key_modifier, "e"])
|
||||
|
||||
expect(composer.value).to eq("`indent preformatted text by 4 spaces`")
|
||||
|
@ -71,8 +71,8 @@ RSpec.describe "Shortcuts | chat composer", type: :system, js: true do
|
|||
chat.visit_channel(channel_1)
|
||||
channel_page.message_thread_indicator(thread.original_message).click
|
||||
|
||||
composer = find(".chat-composer-input--channel")
|
||||
thread_composer = find(".chat-composer-input--thread")
|
||||
composer = find(".chat-channel .chat-composer__input")
|
||||
thread_composer = find(".chat-thread .chat-composer__input")
|
||||
composer.send_keys([key_modifier, "i"])
|
||||
|
||||
expect(composer.value).to eq("_emphasized text_")
|
||||
|
@ -98,7 +98,7 @@ RSpec.describe "Shortcuts | chat composer", type: :system, js: true do
|
|||
chat.visit_channel(channel_1)
|
||||
expect(channel_page).to have_message(id: message_1.id)
|
||||
|
||||
find(".chat-composer-input").send_keys(:arrow_up)
|
||||
find(".chat-composer__input").send_keys(:arrow_up)
|
||||
|
||||
expect(page.find(".chat-composer-message-details")).to have_content(message_1.message)
|
||||
end
|
||||
|
@ -111,7 +111,7 @@ RSpec.describe "Shortcuts | chat composer", type: :system, js: true do
|
|||
page.driver.browser.network_conditions = { offline: true }
|
||||
channel_page.send_message("Hello world")
|
||||
|
||||
find(".chat-composer-input").send_keys(:arrow_up)
|
||||
find(".chat-composer__input").send_keys(:arrow_up)
|
||||
|
||||
expect(page).to have_no_css(".chat-composer-message-details")
|
||||
end
|
||||
|
|
|
@ -36,7 +36,7 @@ RSpec.describe "Shortcuts | drawer", type: :system, js: true do
|
|||
expect(page).to have_css(".chat-drawer.is-expanded")
|
||||
|
||||
drawer.open_channel(channel_1)
|
||||
find(".chat-composer-input").send_keys(:escape)
|
||||
find(".chat-composer__input").send_keys(:escape)
|
||||
|
||||
expect(page).to have_no_css(".chat-drawer.is-expanded")
|
||||
end
|
||||
|
@ -49,7 +49,7 @@ RSpec.describe "Shortcuts | drawer", type: :system, js: true do
|
|||
|
||||
page.send_keys("e")
|
||||
|
||||
expect(find(".chat-composer-input").value).to eq("")
|
||||
expect(find(".chat-composer__input").value).to eq("")
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -59,15 +59,15 @@ RSpec.describe "Shortcuts | drawer", type: :system, js: true do
|
|||
|
||||
expect(page).to have_selector(".chat-drawer[data-chat-channel-id=\"#{channel_1.id}\"]")
|
||||
|
||||
find(".chat-composer-input").send_keys(%i[alt arrow_down])
|
||||
find(".chat-composer__input").send_keys(%i[alt arrow_down])
|
||||
|
||||
expect(page).to have_selector(".chat-drawer[data-chat-channel-id=\"#{channel_2.id}\"]")
|
||||
|
||||
find(".chat-composer-input").send_keys(%i[alt arrow_down])
|
||||
find(".chat-composer__input").send_keys(%i[alt arrow_down])
|
||||
|
||||
expect(page).to have_selector(".chat-drawer[data-chat-channel-id=\"#{channel_1.id}\"]")
|
||||
|
||||
find(".chat-composer-input").send_keys(%i[alt arrow_up])
|
||||
find(".chat-composer__input").send_keys(%i[alt arrow_up])
|
||||
|
||||
expect(page).to have_selector(".chat-drawer[data-chat-channel-id=\"#{channel_2.id}\"]")
|
||||
end
|
||||
|
|
|
@ -19,7 +19,7 @@ RSpec.describe "Shortcuts | full page", type: :system, js: true do
|
|||
|
||||
page.send_keys("e")
|
||||
|
||||
expect(find(".chat-composer-input").value).to eq("e")
|
||||
expect(find(".chat-composer__input").value).to eq("e")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -182,7 +182,7 @@ RSpec.describe "Sidebar navigation menu", type: :system, js: true do
|
|||
visit("/")
|
||||
|
||||
expect(sidebar_page.dms_section.find(".channel-#{dm_channel_1.id}")["title"]).to eq(
|
||||
"Chat with @<script>alert('hello')</script>",
|
||||
"Chat in @<script>alert('hello')</script>",
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -27,10 +27,10 @@ RSpec.describe "Silenced user", type: :system, js: true do
|
|||
)
|
||||
end
|
||||
|
||||
it "removes the send button" do
|
||||
it "disables the send button" do
|
||||
chat.visit_channel(channel_1)
|
||||
|
||||
expect(page).to have_css(".send-btn[disabled]")
|
||||
expect(page).to have_css(".chat-composer__send-btn[disabled]")
|
||||
end
|
||||
|
||||
it "prevents reactions" do
|
||||
|
|
|
@ -25,7 +25,6 @@ describe "Uploading files in chat messages", type: :system, js: true do
|
|||
end
|
||||
|
||||
expect(page).to have_css(".chat-composer-upload .preview .preview-img")
|
||||
expect(page).to have_content(File.basename(file_path))
|
||||
|
||||
channel.send_message("upload testing")
|
||||
|
||||
|
@ -61,7 +60,6 @@ describe "Uploading files in chat messages", type: :system, js: true do
|
|||
channel.click_action_button("chat-upload-btn")
|
||||
end
|
||||
|
||||
expect(page).to have_content(File.basename(file_path))
|
||||
expect(find(".chat-composer-upload")).to have_content("Processing")
|
||||
|
||||
# image processing clientside is slow! here we are waiting for processing
|
||||
|
@ -101,10 +99,11 @@ describe "Uploading files in chat messages", type: :system, js: true do
|
|||
it "allows deleting uploads" do
|
||||
chat.visit_channel(channel_1)
|
||||
channel.open_edit_message(message_2)
|
||||
find(".chat-composer-upload").find(".remove-upload").click
|
||||
find(".chat-composer-upload").hover
|
||||
find(".chat-composer-upload__remove-btn").click
|
||||
channel.click_send_message
|
||||
expect(channel.message_by_id(message_2.id)).not_to have_css(".chat-uploads")
|
||||
expect(message_2.reload.uploads).to be_empty
|
||||
try_until_success(timeout: 5) { expect(message_2.reload.uploads).to be_empty }
|
||||
end
|
||||
|
||||
it "allows adding more uploads" do
|
||||
|
@ -118,13 +117,13 @@ describe "Uploading files in chat messages", type: :system, js: true do
|
|||
end
|
||||
|
||||
expect(page).to have_css(".chat-composer-upload .preview .preview-img", count: 2)
|
||||
expect(page).to have_content(File.basename(file_path))
|
||||
|
||||
channel.click_send_message
|
||||
|
||||
expect(page).not_to have_css(".chat-composer-upload")
|
||||
expect(page).to have_css(".chat-img-upload", count: 2)
|
||||
expect(message_2.reload.uploads.count).to eq(2)
|
||||
|
||||
try_until_success(timeout: 5) { expect(message_2.reload.uploads.count).to eq(2) }
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -58,12 +58,12 @@ acceptance("Discourse Chat - Composer", function (needs) {
|
|||
};
|
||||
|
||||
document
|
||||
.querySelector(".chat-composer-input")
|
||||
.querySelector(".chat-composer__input")
|
||||
.dispatchEvent(clipboardEvent);
|
||||
|
||||
await settled();
|
||||
|
||||
assert.equal(document.querySelector(".chat-composer-input").value, "Foo");
|
||||
assert.equal(document.querySelector(".chat-composer__input").value, "Foo");
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -97,7 +97,7 @@ acceptance("Discourse Chat - Composer - unreliable network", function (needs) {
|
|||
|
||||
skip("Sending a message with unreliable network", async function (assert) {
|
||||
await visit("/chat/c/-/11");
|
||||
await fillIn(".chat-composer-input", "network-error-message");
|
||||
await fillIn(".chat-composer__input", "network-error-message");
|
||||
await click(".send-btn");
|
||||
|
||||
assert.ok(
|
||||
|
@ -105,7 +105,7 @@ acceptance("Discourse Chat - Composer - unreliable network", function (needs) {
|
|||
"it adds a retry button"
|
||||
);
|
||||
|
||||
await fillIn(".chat-composer-input", "network-error-message");
|
||||
await fillIn(".chat-composer__input", "network-error-message");
|
||||
await click(".send-btn");
|
||||
await publishToMessageBus(`/chat/11`, {
|
||||
type: "sent",
|
||||
|
@ -126,7 +126,7 @@ acceptance("Discourse Chat - Composer - unreliable network", function (needs) {
|
|||
"it sends the message"
|
||||
);
|
||||
assert.strictEqual(
|
||||
query(".chat-composer-input").value,
|
||||
query(".chat-composer__input").value,
|
||||
"",
|
||||
"it clears the input"
|
||||
);
|
||||
|
|
|
@ -15,20 +15,17 @@ module(
|
|||
pretender.get("/chat/emojis.json", () => [200, [], {}]);
|
||||
|
||||
this.currentUser.set("id", 1);
|
||||
this.set(
|
||||
"chatChannel",
|
||||
ChatChannel.create({
|
||||
this.channel = ChatChannel.create({
|
||||
chatable_type: "DirectMessage",
|
||||
chatable: {
|
||||
users: [{ id: 1 }],
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await render(hbs`<ChatComposer @chatChannel={{this.chatChannel}} />`);
|
||||
await render(hbs`<Chat::Composer::Channel @channel={{this.channel}} />`);
|
||||
|
||||
assert.strictEqual(
|
||||
query(".chat-composer-input").placeholder,
|
||||
query(".chat-composer__input").placeholder,
|
||||
"Jot something down"
|
||||
);
|
||||
});
|
||||
|
@ -36,9 +33,7 @@ module(
|
|||
test("direct message to multiple folks shows their names", async function (assert) {
|
||||
pretender.get("/chat/emojis.json", () => [200, [], {}]);
|
||||
|
||||
this.set(
|
||||
"chatChannel",
|
||||
ChatChannel.create({
|
||||
this.channel = ChatChannel.create({
|
||||
chatable_type: "DirectMessage",
|
||||
chatable: {
|
||||
users: [
|
||||
|
@ -47,13 +42,12 @@ module(
|
|||
{ username: "zorro" },
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await render(hbs`<ChatComposer @chatChannel={{this.chatChannel}} />`);
|
||||
await render(hbs`<Chat::Composer::Channel @channel={{this.channel}} />`);
|
||||
|
||||
assert.strictEqual(
|
||||
query(".chat-composer-input").placeholder,
|
||||
query(".chat-composer__input").placeholder,
|
||||
"Chat with Tomtom, Steaky, @zorro"
|
||||
);
|
||||
});
|
||||
|
@ -61,19 +55,16 @@ module(
|
|||
test("message to channel shows send message to channel name", async function (assert) {
|
||||
pretender.get("/chat/emojis.json", () => [200, [], {}]);
|
||||
|
||||
this.set(
|
||||
"chatChannel",
|
||||
ChatChannel.create({
|
||||
this.channel = ChatChannel.create({
|
||||
chatable_type: "Category",
|
||||
title: "just-cats",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await render(hbs`<ChatComposer @chatChannel={{this.chatChannel}} />`);
|
||||
await render(hbs`<Chat::Composer::Channel @channel={{this.channel}} />`);
|
||||
|
||||
assert.strictEqual(
|
||||
query(".chat-composer-input").placeholder,
|
||||
"Chat with #just-cats"
|
||||
query(".chat-composer__input").placeholder,
|
||||
"Chat in #just-cats"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -86,8 +86,6 @@ module("Discourse Chat | Component | chat-composer-upload", function (hooks) {
|
|||
);
|
||||
|
||||
assert.true(exists("img.preview-img[src='/images/avatar.png']"));
|
||||
assert.strictEqual(query(".file-name").innerText.trim(), "bar_image.png");
|
||||
assert.strictEqual(query(".extension-pill").innerText.trim(), "png");
|
||||
});
|
||||
|
||||
test("removing completed upload", async function (assert) {
|
||||
|
@ -106,7 +104,7 @@ module("Discourse Chat | Component | chat-composer-upload", function (hooks) {
|
|||
hbs`<ChatComposerUpload @isDone={{true}} @upload={{this.upload}} @onCancel={{fn this.removeUpload this.upload}} />`
|
||||
);
|
||||
|
||||
await click(".remove-upload");
|
||||
await click(".chat-composer-upload__remove-btn");
|
||||
assert.strictEqual(this.uploadRemoved, true);
|
||||
});
|
||||
|
||||
|
@ -126,7 +124,7 @@ module("Discourse Chat | Component | chat-composer-upload", function (hooks) {
|
|||
hbs`<ChatComposerUpload @upload={{this.upload}} @onCancel={{fn this.removeUpload this.upload}} />`
|
||||
);
|
||||
|
||||
await click(".remove-upload");
|
||||
await click(".chat-composer-upload__remove-btn");
|
||||
assert.strictEqual(this.uploadRemoved, true);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -97,7 +97,7 @@ module("Discourse Chat | Component | chat-composer-uploads", function (hooks) {
|
|||
|
||||
assert.dom(".chat-composer-upload").exists({ count: 1 });
|
||||
|
||||
await click(".remove-upload");
|
||||
await click(".chat-composer-upload__remove-btn");
|
||||
|
||||
assert.dom(".chat-composer-upload").exists({ count: 0 });
|
||||
});
|
||||
|
@ -138,7 +138,7 @@ module("Discourse Chat | Component | chat-composer-uploads", function (hooks) {
|
|||
await waitFor(".chat-composer-upload");
|
||||
assert.strictEqual(count(".chat-composer-upload"), 1);
|
||||
|
||||
await click(".remove-upload");
|
||||
await click(".chat-composer-upload__remove-btn");
|
||||
assert.strictEqual(count(".chat-composer-upload"), 0);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -296,7 +296,7 @@ RSpec.configure do |config|
|
|||
Selenium::WebDriver::Chrome::Options
|
||||
.new(logging_prefs: { "browser" => "INFO", "driver" => "ALL" })
|
||||
.tap do |options|
|
||||
options.add_argument("--window-size=390,950")
|
||||
options.add_argument("--window-size=390,960")
|
||||
options.add_argument("--no-sandbox")
|
||||
options.add_argument("--disable-dev-shm-usage")
|
||||
options.add_emulation(device_name: "iPhone 12 Pro")
|
||||
|
|
Loading…
Reference in New Issue