discourse/plugins/chat/assets/javascripts/discourse/components/chat-thread.gjs
Joffrey JAFFEUX b546c31b7f
UI: simplify chat thread title (#29998)
We were using a complex logic to make it change size based on scroll position but this was imperfect and not visually pleasing. Also the title had been made a button which was causing the ellipsis to not work correctly, and I would prefer to not mix page knowledge (thread) with title component so I made this click logic directly in the chat-thread component.

---------

Co-authored-by: Jordan Vidrine <jordan@jordanvidrine.com>
2024-11-29 22:39:18 +01:00

601 lines
16 KiB
Plaintext

import Component from "@glimmer/component";
import { cached, tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { getOwner } from "@ember/owner";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
import { cancel, next } from "@ember/runloop";
import { service } from "@ember/service";
import concatClass from "discourse/helpers/concat-class";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { NotificationLevels } from "discourse/lib/notification-levels";
import discourseDebounce from "discourse-common/lib/debounce";
import { bind } from "discourse-common/utils/decorators";
import { i18n } from "discourse-i18n";
import ChatThreadTitlePrompt from "discourse/plugins/chat/discourse/components/chat-thread-title-prompt";
import firstVisibleMessageId from "discourse/plugins/chat/discourse/helpers/first-visible-message-id";
import ChatChannelThreadSubscriptionManager from "discourse/plugins/chat/discourse/lib/chat-channel-thread-subscription-manager";
import {
FUTURE,
PAST,
READ_INTERVAL_MS,
} from "discourse/plugins/chat/discourse/lib/chat-constants";
import { stackingContextFix } from "discourse/plugins/chat/discourse/lib/chat-ios-hacks";
import ChatMessagesLoader from "discourse/plugins/chat/discourse/lib/chat-messages-loader";
import DatesSeparatorsPositioner from "discourse/plugins/chat/discourse/lib/dates-separators-positioner";
import { extractCurrentTopicInfo } from "discourse/plugins/chat/discourse/lib/extract-current-topic-info";
import {
scrollListToBottom,
scrollListToMessage,
scrollListToTop,
} from "discourse/plugins/chat/discourse/lib/scroll-helpers";
import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
import UserChatThreadMembership from "discourse/plugins/chat/discourse/models/user-chat-thread-membership";
import ChatComposerThread from "./chat/composer/thread";
import ChatScrollToBottomArrow from "./chat/scroll-to-bottom-arrow";
import ChatSelectionManager from "./chat/selection-manager";
import Message from "./chat-message";
import ChatMessagesContainer from "./chat-messages-container";
import ChatMessagesScroller from "./chat-messages-scroller";
import ChatSkeleton from "./chat-skeleton";
import ChatThreadHeading from "./chat-thread-heading";
import ChatUploadDropZone from "./chat-upload-drop-zone";
export default class ChatThread extends Component {
@service appEvents;
@service capabilities;
@service chat;
@service chatApi;
@service chatComposerPresenceManager;
@service chatHistory;
@service chatDraftsManager;
@service chatThreadComposer;
@service chatThreadPane;
@service dialog;
@service currentUser;
@service router;
@service siteSettings;
@tracked atBottom = true;
@tracked isScrolling = false;
@tracked needsArrow = false;
@tracked uploadDropZone;
scroller = null;
@cached
get messagesLoader() {
return new ChatMessagesLoader(getOwner(this), this.args.thread);
}
get messagesManager() {
return this.args.thread.messagesManager;
}
@action
handleKeydown(event) {
if (event.key === "Escape") {
return this.router.transitionTo(
"chat.channel",
...this.args.thread.channel.routeModels
);
}
}
@action
setup(element) {
this.uploadDropZone = element;
this.messagesManager.clear();
this.args.thread.draft =
this.chatDraftsManager.get(
this.args.thread.channel?.id,
this.args.thread.id
) ||
ChatMessage.createDraftMessage(this.args.thread.channel, {
user: this.currentUser,
thread: this.args.thread,
});
this.chatThreadComposer.focus();
this.loadMessages();
}
@action
teardown() {
this.subscriptionManager.teardown();
cancel(this._debouncedFillPaneAttemptHandler);
cancel(this._debounceUpdateLastReadMessageHandler);
}
@action
onScroll(state) {
next(() => {
if (this.#flushIgnoreNextScroll()) {
return;
}
DatesSeparatorsPositioner.apply(this.scroller);
this.needsArrow =
(this.messagesLoader.fetchedOnce &&
this.messagesLoader.canLoadMoreFuture) ||
(state.distanceToBottom.pixels > 250 && !state.atBottom);
this.isScrolling = true;
this.debounceUpdateLastReadMessage();
if (
state.atTop ||
(!this.capabilities.isIOS &&
state.up &&
state.distanceToTop.percentage < 40)
) {
this.fetchMoreMessages({ direction: PAST });
} else if (state.atBottom) {
this.fetchMoreMessages({ direction: FUTURE });
}
});
}
@action
onScrollEnd(state) {
this.needsArrow =
(this.messagesLoader.fetchedOnce &&
this.messagesLoader.canLoadMoreFuture) ||
(state.distanceToBottom.pixels > 250 && !state.atBottom);
this.isScrolling = false;
this.atBottom = state.atBottom;
if (state.atBottom) {
this.fetchMoreMessages({ direction: FUTURE });
}
}
debounceUpdateLastReadMessage() {
this._debounceUpdateLastReadMessageHandler = discourseDebounce(
this,
this.updateLastReadMessage,
READ_INTERVAL_MS
);
}
@bind
updateLastReadMessage() {
if (!this.args.thread?.currentUserMembership) {
return;
}
const firstFullyVisibleMessageId = firstVisibleMessageId(this.scroller);
if (!firstFullyVisibleMessageId) {
return;
}
const firstMessage = this.messagesManager.findMessage(
firstFullyVisibleMessageId
);
if (!firstMessage) {
return;
}
const lastReadId = this.args.thread.currentUserMembership.lastReadMessageId;
if (lastReadId >= firstMessage.id) {
return;
}
return this.chatApi.markThreadAsRead(
this.args.thread.channel.id,
this.args.thread.id,
firstMessage.id
);
}
@action
registerScroller(element) {
this.scroller = element;
}
@action
loadMessages() {
this.fetchMessages();
this.subscriptionManager = new ChatChannelThreadSubscriptionManager(
this,
this.args.thread,
{ onNewMessage: this.onNewMessage }
);
}
@action
didResizePane() {
this._ignoreNextScroll = true;
this.debounceFillPaneAttempt();
this.debounceUpdateLastReadMessage();
DatesSeparatorsPositioner.apply(this.scroller);
}
async fetchMessages(findArgs = {}) {
if (this.messagesLoader.loading) {
return;
}
this.messagesManager.clear();
findArgs.target_message_id ??=
findArgs.targetMessageId ||
this.args.targetMessageId ||
this.args.thread.currentUserMembership?.lastReadMessageId;
if (!findArgs.target_message_id) {
findArgs.direction = FUTURE;
}
const result = await this.messagesLoader.load(findArgs);
if (!result) {
return;
}
const [messages, meta] = this.processMessages(this.args.thread, result);
stackingContextFix(this.scroller, () => {
this.messagesManager.addMessages(messages);
});
this.args.thread.details = meta;
if (meta.target_message_id) {
this.scrollToMessageId(meta.target_message_id, { highlight: true });
} else {
this.scrollToTop();
}
this.debounceFillPaneAttempt();
}
@action
async fetchMoreMessages({ direction }) {
if (this.messagesLoader.loading) {
return;
}
const result = await this.messagesLoader.loadMore({ direction });
if (!result) {
return;
}
const [messages, meta] = this.processMessages(this.args.thread, result);
if (!messages?.length) {
return;
}
stackingContextFix(this.scroller, () => {
this.messagesManager.addMessages(messages);
});
this.args.thread.details = meta;
if (direction === FUTURE) {
this.scrollToMessageId(messages.firstObject.id, {
position: "end",
behavior: "auto",
});
} else if (direction === PAST) {
this.scrollToMessageId(messages.lastObject.id);
}
this.debounceFillPaneAttempt();
}
@action
scrollToLatestMessage() {
if (this.messagesLoader.canLoadMoreFuture) {
this.fetchMessages();
} else if (this.messagesManager.messages.length > 0) {
this.scrollToBottom();
}
}
debounceFillPaneAttempt() {
if (!this.messagesLoader.fetchedOnce) {
return;
}
this._debouncedFillPaneAttemptHandler = discourseDebounce(
this,
this.fillPaneAttempt,
500
);
}
async fillPaneAttempt() {
// safeguard
if (this.messagesManager.messages.length > 200) {
return;
}
if (!this.messagesLoader.canLoadMorePast) {
return;
}
const firstMessage = this.messagesManager.messages.firstObject;
if (!firstMessage?.visible) {
return;
}
await this.fetchMoreMessages({ direction: PAST });
}
scrollToMessageId(
messageId,
opts = { highlight: false, position: "start", autoExpand: false }
) {
this._ignoreNextScroll = true;
const message = this.messagesManager.findMessage(messageId);
scrollListToMessage(this.scroller, message, opts);
}
@bind
onNewMessage(message) {
if (!this.atBottom) {
this.needsArrow = true;
this.messagesLoader.canLoadMoreFuture = true;
return;
}
stackingContextFix(this.scroller, () => {
this.messagesManager.addMessages([message]);
});
}
@bind
processMessages(thread, result) {
const messages = result.messages.map((messageData) => {
const ignored = this.currentUser.ignored_users || [];
const hidden = ignored.includes(messageData.user.username);
return ChatMessage.create(thread.channel, {
...messageData,
hidden,
expanded: !(hidden || messageData.deleted_at),
manager: this.messagesManager,
thread,
});
});
return [messages, result.meta];
}
@action
async onSendMessage(message) {
if (
message.message.length > this.siteSettings.chat_maximum_message_length
) {
this.dialog.alert(
i18n("chat.message_too_long", {
count: this.siteSettings.chat_maximum_message_length,
})
);
return;
}
await message.cook();
if (message.editing) {
await this.#sendEditMessage(message);
} else {
await this.#sendNewMessage(message);
}
}
@bind
fetchMessagesByDate(date) {
if (this.messagesLoader.loading) {
return;
}
const message = this.messagesManager.findFirstMessageOfDay(new Date(date));
if (message.firstOfResults && this.messagesLoader.canLoadMorePast) {
this.fetchMessages({ target_date: date, direction: FUTURE });
} else {
this.highlightOrFetchMessage(message.id, { position: "center" });
}
}
@action
highlightOrFetchMessage(messageId, options = {}) {
const message = this.messagesManager.findMessage(messageId);
if (message) {
this.scrollToMessageId(
message.id,
Object.assign(
{
highlight: true,
position: "start",
autoExpand: true,
behavior: this.capabilities.isIOS ? "smooth" : null,
},
options
)
);
} else {
this.fetchMessages({ target_message_id: messageId });
}
}
@action
resetComposerMessage() {
this.args.thread.draft = ChatMessage.createDraftMessage(
this.args.thread.channel,
{
user: this.currentUser,
thread: this.args.thread,
}
);
}
async #sendNewMessage(message) {
if (this.chatThreadPane.sending) {
return;
}
this.chatThreadPane.sending = true;
this._ignoreNextScroll = true;
stackingContextFix(this.scroller, async () => {
await this.args.thread.stageMessage(message);
});
this.resetComposerMessage();
if (!this.messagesLoader.canLoadMoreFuture) {
this.scrollToLatestMessage();
}
try {
const params = {
message: message.message,
in_reply_to_id: null,
staged_id: message.id,
upload_ids: message.uploads.map((upload) => upload.id),
thread_id: message.thread.id,
};
const response = await this.chatApi.sendMessage(
this.args.thread.channel.id,
Object.assign({}, params, extractCurrentTopicInfo(this))
);
this.args.thread.currentUserMembership ??=
UserChatThreadMembership.create({
notification_level: NotificationLevels.TRACKING,
last_read_message_id: response.message_id,
});
this.scrollToLatestMessage();
} catch (error) {
this.#onSendError(message.id, error);
} finally {
this.chatDraftsManager.remove(
this.args.thread.channel.id,
this.args.thread.id
);
this.chatThreadPane.sending = false;
}
}
async #sendEditMessage(message) {
this.chatThreadPane.sending = true;
const data = {
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.chatDraftsManager.remove(
this.args.thread.channel.id,
this.args.thread.id
);
this.chatThreadPane.sending = false;
}
}
@action
async scrollToBottom() {
this._ignoreNextScroll = true;
await scrollListToBottom(this.scroller);
}
@action
async scrollToTop() {
this._ignoreNextScroll = true;
await scrollListToTop(this.scroller);
}
@action
resendStagedMessage() {}
#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();
}
#flushIgnoreNextScroll() {
const prev = this._ignoreNextScroll;
this._ignoreNextScroll = false;
return prev;
}
<template>
<div
class={{concatClass
"chat-thread"
(if this.messagesLoader.loading "loading")
}}
data-id={{@thread.id}}
{{didInsert this.setup}}
{{willDestroy this.teardown}}
>
<ChatMessagesScroller
@onRegisterScroller={{this.registerScroller}}
@onScroll={{this.onScroll}}
@onScrollEnd={{this.onScrollEnd}}
>
<ChatMessagesContainer @didResizePane={{this.didResizePane}}>
{{#each this.messagesManager.messages key="id" as |message|}}
<Message
@message={{message}}
@disableMouseEvents={{this.isScrolling}}
@resendStagedMessage={{this.resendStagedMessage}}
@fetchMessagesByDate={{this.fetchMessagesByDate}}
@context="thread"
/>
{{/each}}
{{#unless this.messagesLoader.fetchedOnce}}
{{#if this.messagesLoader.loading}}
<ChatSkeleton />
{{/if}}
{{/unless}}
</ChatMessagesContainer>
<ChatThreadHeading @thread={{@thread}} />
</ChatMessagesScroller>
<ChatScrollToBottomArrow
@onScrollToBottom={{this.scrollToLatestMessage}}
@isVisible={{this.needsArrow}}
/>
{{#if this.chatThreadPane.selectingMessages}}
<ChatSelectionManager
@pane={{this.chatThreadPane}}
@messagesManager={{this.messagesManager}}
/>
{{else}}
<ChatComposerThread
@channel={{@channel}}
@thread={{@thread}}
@onSendMessage={{this.onSendMessage}}
@uploadDropZone={{this.uploadDropZone}}
@scroller={{this.scroller}}
/>
{{/if}}
<ChatUploadDropZone @model={{@thread}} />
<ChatThreadTitlePrompt @thread={{@thread}} />
</div>
</template>
}