mirror of
https://github.com/discourse/discourse.git
synced 2025-06-09 06:18:14 +08:00

This commit contains multiple changes to improve the composer behavior especially in the context of a thread: - Generally rename anything of the form `chatChannelThread...` to `chatThread...`` - Moves the textarea interactor instance inside the composer server - Improves the focus state and closing of panel related to the use of the Escape shortcut - Creates `Chat::ThreadList` as a component instead of having `Chat::Thread::ListItem` and others which could imply they were children of a the `Chat::Thread` component
396 lines
9.8 KiB
JavaScript
396 lines
9.8 KiB
JavaScript
import Component from "@glimmer/component";
|
|
import { Promise } from "rsvp";
|
|
import { tracked } from "@glimmer/tracking";
|
|
import { action } from "@ember/object";
|
|
import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
|
|
import { popupAjaxError } from "discourse/lib/ajax-error";
|
|
import { bind, debounce } from "discourse-common/utils/decorators";
|
|
import { inject as service } from "@ember/service";
|
|
import { cancel, next, schedule } from "@ember/runloop";
|
|
import discourseLater from "discourse-common/lib/later";
|
|
import { resetIdle } from "discourse/lib/desktop-notifications";
|
|
|
|
const PAGE_SIZE = 100;
|
|
const READ_INTERVAL_MS = 1000;
|
|
|
|
export default class ChatThreadPanel extends Component {
|
|
@service siteSettings;
|
|
@service currentUser;
|
|
@service chat;
|
|
@service router;
|
|
@service chatApi;
|
|
@service chatComposerPresenceManager;
|
|
@service chatThreadComposer;
|
|
@service chatThreadPane;
|
|
@service chatThreadPaneSubscriptionsManager;
|
|
@service appEvents;
|
|
@service capabilities;
|
|
|
|
@tracked loading;
|
|
@tracked uploadDropZone;
|
|
|
|
scrollable = null;
|
|
|
|
@action
|
|
handleKeydown(event) {
|
|
if (event.key === "Escape") {
|
|
return this.router.transitionTo(
|
|
"chat.channel",
|
|
...this.args.thread.channel.routeModels
|
|
);
|
|
}
|
|
}
|
|
|
|
@action
|
|
didUpdateThread() {
|
|
this.subscribeToUpdates();
|
|
this.loadMessages();
|
|
this.resetComposerMessage();
|
|
}
|
|
|
|
@action
|
|
setUploadDropZone(element) {
|
|
this.uploadDropZone = element;
|
|
}
|
|
|
|
@action
|
|
subscribeToUpdates() {
|
|
this.chatThreadPaneSubscriptionsManager.subscribe(this.args.thread);
|
|
}
|
|
|
|
@action
|
|
unsubscribeFromUpdates() {
|
|
this.chatThreadPaneSubscriptionsManager.unsubscribe();
|
|
}
|
|
|
|
// TODO (martin) This needs to have the extended scroll/message visibility/
|
|
// mark read behaviour the same as the channel.
|
|
@action
|
|
computeScrollState() {
|
|
cancel(this.onScrollEndedHandler);
|
|
|
|
if (!this.scrollable) {
|
|
return;
|
|
}
|
|
|
|
this.chat.activeMessage = null;
|
|
|
|
if (this.#isAtBottom()) {
|
|
this.updateLastReadMessage();
|
|
this.onScrollEnded();
|
|
} else {
|
|
this.isScrolling = true;
|
|
this.onScrollEndedHandler = discourseLater(this, this.onScrollEnded, 150);
|
|
}
|
|
}
|
|
|
|
#isAtBottom() {
|
|
if (!this.scrollable) {
|
|
return false;
|
|
}
|
|
|
|
// This is different from the channel scrolling because the scrolling here
|
|
// is inverted -- in the channel's case scrollTop is 0 when scrolled to the
|
|
// bottom of the channel, but in the negatives when scrolling up to past messages.
|
|
//
|
|
// c.f. https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight#determine_if_an_element_has_been_totally_scrolled
|
|
return (
|
|
Math.abs(
|
|
this.scrollable.scrollHeight -
|
|
this.scrollable.clientHeight -
|
|
this.scrollable.scrollTop
|
|
) <= 2
|
|
);
|
|
}
|
|
|
|
@bind
|
|
onScrollEnded() {
|
|
this.isScrolling = false;
|
|
}
|
|
|
|
@debounce(READ_INTERVAL_MS)
|
|
updateLastReadMessage() {
|
|
schedule("afterRender", () => {
|
|
if (this._selfDeleted) {
|
|
return;
|
|
}
|
|
|
|
// TODO (martin) HACK: We don't have proper scroll visibility over
|
|
// what message we are looking at, don't have the lastReadMessageId
|
|
// for the thread, and this updateLastReadMessage function is only
|
|
// called when scrolling all the way to the bottom.
|
|
this.markThreadAsRead();
|
|
});
|
|
}
|
|
|
|
@action
|
|
setScrollable(element) {
|
|
this.scrollable = element;
|
|
}
|
|
|
|
@action
|
|
loadMessages() {
|
|
this.args.thread.messagesManager.clearMessages();
|
|
this.fetchMessages();
|
|
}
|
|
|
|
@action
|
|
didResizePane() {
|
|
this.forceRendering();
|
|
}
|
|
|
|
get _selfDeleted() {
|
|
return this.isDestroying || this.isDestroyed;
|
|
}
|
|
|
|
@debounce(100)
|
|
fetchMessages() {
|
|
if (this._selfDeleted) {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
if (this.args.thread.staged) {
|
|
const message = this.args.thread.originalMessage;
|
|
message.thread = this.args.thread;
|
|
this.args.thread.messagesManager.addMessages([message]);
|
|
return Promise.resolve();
|
|
}
|
|
|
|
this.loading = true;
|
|
|
|
const findArgs = {
|
|
pageSize: PAGE_SIZE,
|
|
threadId: this.args.thread.id,
|
|
includeMessages: true,
|
|
};
|
|
return this.chatApi
|
|
.channel(this.args.thread.channel.id, findArgs)
|
|
.then((result) => {
|
|
if (
|
|
this._selfDeleted ||
|
|
this.args.thread.channel.id !== result.meta.channel_id
|
|
) {
|
|
this.router.transitionTo("chat.channel", "-", result.meta.channel_id);
|
|
}
|
|
|
|
const [messages, meta] = this.afterFetchCallback(
|
|
this.args.thread,
|
|
result
|
|
);
|
|
this.args.thread.messagesManager.addMessages(messages);
|
|
this.args.thread.details = meta;
|
|
this.markThreadAsRead();
|
|
})
|
|
.catch(this.#handleErrors)
|
|
.finally(() => {
|
|
if (this._selfDeleted) {
|
|
return;
|
|
}
|
|
|
|
this.loading = false;
|
|
});
|
|
}
|
|
|
|
@bind
|
|
afterFetchCallback(thread, result) {
|
|
const messages = [];
|
|
|
|
result.chat_messages.forEach((messageData) => {
|
|
// If a message has been hidden it is because the current user is ignoring
|
|
// the user who sent it, so we want to unconditionally hide it, even if
|
|
// we are going directly to the target
|
|
if (this.currentUser.ignored_users) {
|
|
messageData.hidden = this.currentUser.ignored_users.includes(
|
|
messageData.user.username
|
|
);
|
|
}
|
|
|
|
messageData.expanded = !(messageData.hidden || messageData.deleted_at);
|
|
const message = ChatMessage.create(thread.channel, messageData);
|
|
message.thread = thread;
|
|
messages.push(message);
|
|
});
|
|
|
|
return [messages, result.meta];
|
|
}
|
|
|
|
// NOTE: At some point we want to do this based on visible messages
|
|
// and scrolling; for now it's enough to do it when the thread panel
|
|
// opens/messages are loaded since we have no pagination for threads.
|
|
markThreadAsRead() {
|
|
return this.chatApi.markThreadAsRead(
|
|
this.args.thread.channel.id,
|
|
this.args.thread.id
|
|
);
|
|
}
|
|
|
|
@action
|
|
async onSendMessage(message) {
|
|
resetIdle();
|
|
|
|
if (message.editing) {
|
|
await this.#sendEditMessage(message);
|
|
} else {
|
|
await this.#sendNewMessage(message);
|
|
}
|
|
}
|
|
|
|
@action
|
|
resetComposerMessage() {
|
|
this.chatThreadComposer.reset(this.args.thread);
|
|
}
|
|
|
|
async #sendNewMessage(message) {
|
|
if (this.chatThreadPane.sending) {
|
|
return;
|
|
}
|
|
|
|
this.chatThreadPane.sending = true;
|
|
await this.args.thread.stageMessage(message);
|
|
this.resetComposerMessage();
|
|
this.scrollToBottom();
|
|
|
|
try {
|
|
await this.chatApi
|
|
.sendMessage(this.args.thread.channel.id, {
|
|
message: message.message,
|
|
in_reply_to_id: message.thread.staged
|
|
? message.thread.originalMessage.id
|
|
: null,
|
|
staged_id: message.id,
|
|
upload_ids: message.uploads.map((upload) => upload.id),
|
|
thread_id: message.thread.staged ? null : message.thread.id,
|
|
staged_thread_id: message.thread.staged ? message.thread.id : null,
|
|
})
|
|
.catch((error) => {
|
|
this.#onSendError(message.id, error);
|
|
})
|
|
.finally(() => {
|
|
if (this._selfDeleted) {
|
|
return;
|
|
}
|
|
this.chatThreadPane.sending = false;
|
|
});
|
|
} catch (error) {
|
|
this.#onSendError(message.id, error);
|
|
} finally {
|
|
if (!this._selfDeleted) {
|
|
this.chatThreadPane.sending = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
async #sendEditMessage(message) {
|
|
await message.cook();
|
|
this.chatThreadPane.sending = true;
|
|
|
|
const data = {
|
|
new_message: message.message,
|
|
upload_ids: message.uploads.map((upload) => upload.id),
|
|
};
|
|
|
|
this.resetComposerMessage();
|
|
|
|
try {
|
|
return await this.chatApi.editMessage(
|
|
message.channel.id,
|
|
message.id,
|
|
data
|
|
);
|
|
} catch (e) {
|
|
popupAjaxError(e);
|
|
} finally {
|
|
this.chatThreadPane.sending = false;
|
|
}
|
|
}
|
|
|
|
// A more consistent way to scroll to the bottom when we are sure this is our goal
|
|
// it will also limit issues with any element changing the height while we are scrolling
|
|
// to the bottom
|
|
@action
|
|
scrollToBottom() {
|
|
next(() => {
|
|
schedule("afterRender", () => {
|
|
if (!this.scrollable) {
|
|
return;
|
|
}
|
|
|
|
this.scrollable.scrollTop = this.scrollable.scrollHeight + 1;
|
|
this.forceRendering(() => {
|
|
this.scrollable.scrollTop = this.scrollable.scrollHeight;
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
// since -webkit-overflow-scrolling: touch can't be used anymore to disable momentum scrolling
|
|
// we now use this hack to disable it
|
|
@bind
|
|
forceRendering(callback) {
|
|
schedule("afterRender", () => {
|
|
if (this._selfDeleted) {
|
|
return;
|
|
}
|
|
|
|
if (!this.scrollable) {
|
|
return;
|
|
}
|
|
|
|
if (this.capabilities.isIOS) {
|
|
this.scrollable.style.overflow = "hidden";
|
|
}
|
|
|
|
callback?.();
|
|
|
|
if (this.capabilities.isIOS) {
|
|
discourseLater(() => {
|
|
if (!this.scrollable) {
|
|
return;
|
|
}
|
|
|
|
this.scrollable.style.overflow = "auto";
|
|
}, 50);
|
|
}
|
|
});
|
|
}
|
|
|
|
@action
|
|
resendStagedMessage() {}
|
|
|
|
@action
|
|
messageDidEnterViewport(message) {
|
|
message.visible = true;
|
|
}
|
|
|
|
@action
|
|
messageDidLeaveViewport(message) {
|
|
message.visible = false;
|
|
}
|
|
|
|
#handleErrors(error) {
|
|
switch (error?.jqXHR?.status) {
|
|
case 429:
|
|
case 404:
|
|
popupAjaxError(error);
|
|
break;
|
|
default:
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
#onSendError(stagedId, error) {
|
|
const stagedMessage =
|
|
this.args.thread.messagesManager.findStagedMessage(stagedId);
|
|
if (stagedMessage) {
|
|
if (error.jqXHR?.responseJSON?.errors?.length) {
|
|
stagedMessage.error = error.jqXHR.responseJSON.errors[0];
|
|
} else {
|
|
this.chat.markNetworkAsUnreliable();
|
|
stagedMessage.error = "network_error";
|
|
}
|
|
}
|
|
|
|
this.resetComposerMessage();
|
|
}
|
|
}
|