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:
Joffrey JAFFEUX 2023-04-25 10:23:03 +02:00 committed by GitHub
parent 02625d1edd
commit bf886662df
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
82 changed files with 1872 additions and 1337 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 shouldnt
this._textarea.style.height = this._textarea.scrollHeight + 1 + "px";
});
},
});
}

View File

@ -19,6 +19,6 @@
/>
{{#if this.previewedChannel}}
<ChatLivePane @channel={{this.previewedChannel}} @includeHeader={{false}} />
<ChatChannel @channel={{this.previewedChannel}} @includeHeader={{false}} />
{{/if}}
</div>

View File

@ -16,7 +16,7 @@
{{did-update this.fetchChannel @params.channelId}}
>
{{#if this.chat.activeChannel}}
<ChatLivePane
<ChatChannel
@targetMessageId={{readonly @params.messageId}}
@channel={{this.chat.activeChannel}}
/>

View File

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

View File

@ -0,0 +1 @@
<div class="chat-side-panel-resizer"></div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
{{#if @channel.id}}
<ChatLivePane
<ChatChannel
@channel={{@channel}}
@targetMessageId={{readonly @targetMessageId}}
/>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 shouldnt
this._textarea.style.height = this._textarea.scrollHeight + 1 + "px";
});
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -37,7 +37,7 @@
}
}
.chat-composer-container {
.chat-composer__wrapper {
padding-bottom: 0.5em;
}
}

View File

@ -94,7 +94,7 @@ html.rtl {
height: auto !important;
}
.chat-live-pane {
.chat-channel {
height: 100%;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,7 +2,7 @@
#skip-link,
.d-header,
.chat-message-actions-mobile-outlet,
.chat-live-pane,
.chat-channel,
.chat-thread {
> * {
@include user-select(none);

View File

@ -6,3 +6,4 @@
@import "chat-message";
@import "chat-selection-manager";
@import "chat-emoji-picker";
@import "chat-composer-upload";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 @&lt;script&gt;alert(&#x27;hello&#x27;)&lt;/script&gt;",
"Chat in @&lt;script&gt;alert(&#x27;hello&#x27;)&lt;/script&gt;",
)
end
end

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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