discourse/plugins/chat/assets/javascripts/discourse/components/chat-channel.gjs
Sérgio Saquetim 1654d529ef
DEV: Consolidate reusable components into ui-kit (#38703)
Consolidates reusable UI primitives from `app/components/`,
`app/helpers/`, and `app/modifiers/` into a dedicated `app/ui-kit/`
directory under a unified `d-` prefix naming convention. This gives
Discourse a single, discoverable home for the building-block parts of
the UI: generic primitives (buttons, inputs, modals, selects), layout
pieces (page headers, breadcrumbs, stat tiles), and lightly
Discourse-flavored display widgets (user info, badges, cook-text).

Helpers and modifiers live in `app/ui-kit/helpers/` and
`app/ui-kit/modifiers/` respectively.

### Backward compatibility

No existing imports or template invocations need to change. Old import
paths (e.g. `discourse/components/d-button`, `discourse/helpers/d-icon`)
keep working via runtime AMD `loaderShim` entries in
`app/ui-kit-shims.js`, so plugins and themes need zero changes.

An ESLint rule (shipped via `@discourse/lint-configs` 2.46.0) auto-fixes
imports in the codebase to use the new `discourse/ui-kit/...` paths.
Existing consumer files in `app/`, `admin/`, `plugins/`, and `frontend/`
have been re-imported through that rule in a single sweep, so the
codebase stays consistent.

### Tests

Test module identifiers and file paths now match the ui-kit directory
layout (`tests/integration/ui-kit/...`, `module("Integration | ui-kit |
...")`), keeping the test tree symmetric with `app/ui-kit/`.
2026-05-11 18:07:36 -03:00

788 lines
22 KiB
Text
Vendored

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 didUpdate from "@ember/render-modifiers/modifiers/did-update";
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
import { cancel, next, schedule } from "@ember/runloop";
import { service } from "@ember/service";
import { popupAjaxError } from "discourse/lib/ajax-error";
import discourseDebounce from "discourse/lib/debounce";
import { bind } from "discourse/lib/decorators";
import DiscourseURL from "discourse/lib/url";
import { and, not } from "discourse/truth-helpers";
import dConcatClass from "discourse/ui-kit/helpers/d-concat-class";
import { i18n } from "discourse-i18n";
import ChatChannelStatus from "discourse/plugins/chat/discourse/components/chat-channel-status";
import firstVisibleMessageId from "discourse/plugins/chat/discourse/helpers/first-visible-message-id";
import ChatChannelSubscriptionManager from "discourse/plugins/chat/discourse/lib/chat-channel-subscription-manager";
import {
FUTURE,
PAST,
READ_INTERVAL_MS,
} from "discourse/plugins/chat/discourse/lib/chat-constants";
import ChatMessagesLoader from "discourse/plugins/chat/discourse/lib/chat-messages-loader";
import ChatPaneState from "discourse/plugins/chat/discourse/lib/chat-pane-state";
import { checkMessageTopVisibility } from "discourse/plugins/chat/discourse/lib/check-message-visibility";
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,
} from "discourse/plugins/chat/discourse/lib/scroll-helpers";
import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
import ChatComposerChannel from "./chat/composer/channel";
import ChatScrollToBottomArrow from "./chat/scroll-to-bottom-arrow";
import ChatSelectionManager from "./chat/selection-manager";
import ChatChannelFilter from "./chat-channel-filter";
import ChatChannelPreviewCard from "./chat-channel-preview-card";
import ChatMentionWarnings from "./chat-mention-warnings";
import Message from "./chat-message";
import ChatMessagesContainer from "./chat-messages-container";
import ChatMessagesScroller from "./chat-messages-scroller";
import ChatNotices from "./chat-notices";
import ChatSkeleton from "./chat-skeleton";
import ChatUploadDropZone from "./chat-upload-drop-zone";
export default class ChatChannel extends Component {
@service capabilities;
@service chatApi;
@service chatChannelsManager;
@service chatDraftsManager;
@service chatStateManager;
@service chatChannelScrollPositions;
@service("chat-channel-composer") composer;
@service("chat-channel-pane") pane;
@service currentUser;
@service dialog;
@service siteSettings;
@tracked atBottom = true;
@tracked uploadDropZone;
@tracked isScrolling = false;
scroller = null;
paneState = new ChatPaneState(getOwner(this), {
contextKey: this.pendingContextKey,
onUserPresent: this.debouncedUpdateLastReadMessage,
});
_mentionWarningsSeen = {};
_unreachableGroupMentions = [];
_overMembersLimitGroupMentions = [];
@action
registerScroller(element) {
this.scroller = element;
}
@cached
get messagesLoader() {
return new ChatMessagesLoader(getOwner(this), this.args.channel);
}
get messagesManager() {
return this.args.channel.messagesManager;
}
get currentUserMembership() {
return this.args.channel?.currentUserMembership;
}
get hasSavedScrollPosition() {
return !!this.chatChannelScrollPositions.get(this.args.channel.id);
}
get pendingContextKey() {
return this.args.channel?.id ? `channel:${this.args.channel.id}` : null;
}
@action
teardown() {
document.removeEventListener("keydown", this._autoFocus);
this.#cancelHandlers();
this.paneState.teardown();
this.subscriptionManager.teardown();
this.updateLastReadMessage();
// Cancel any pending search request and debounced calls
cancel(this, this._performSearch);
this.searchRequest?.abort?.();
this.searchRequest = null;
}
@action
didResizePane() {
this.debounceFillPaneAttempt();
this.debouncedUpdateLastReadMessage();
DatesSeparatorsPositioner.apply(this.scroller);
this.paneState.updatePendingContentFromScrollerPosition({
scroller: this.scroller,
fetchedOnce: this.messagesLoader.fetchedOnce,
canLoadMoreFuture: this.messagesLoader.canLoadMoreFuture,
});
}
@action
setup(element) {
this.uploadDropZone = element;
document.addEventListener("keydown", this._autoFocus);
this.messagesManager.clear();
if (
this.args.channel.isDirectMessageChannel &&
!this.args.channel.isFollowing
) {
this.chatChannelsManager.follow(this.args.channel);
}
this.args.channel.draft =
this.chatDraftsManager.get(this.args.channel?.id) ||
ChatMessage.createDraftMessage(this.args.channel, {
user: this.currentUser,
});
this.composer.focus();
this.loadMessages();
// We update this value server-side when we load the Channel
// here, so this reflects reality for sidebar unread logic.
this.args.channel.updateLastViewedAt();
}
@action
onLoadTargetMessageId(targetMessageId) {
this.loadMessages(null, targetMessageId);
}
@action
loadMessages(_element, targetMessageId) {
targetMessageId ??= this.args.targetMessageId;
if (!this.args.channel?.id) {
return;
}
this.subscriptionManager = new ChatChannelSubscriptionManager(
this,
this.args.channel,
{ onNewMessage: this.onNewMessage }
);
if (targetMessageId) {
this.debounceHighlightOrFetchMessage(targetMessageId);
} else if (this.chatChannelScrollPositions.get(this.args.channel.id)) {
this.debounceHighlightOrFetchMessage(
this.chatChannelScrollPositions.get(this.args.channel.id)
);
} else {
this.fetchMessages({ fetch_from_last_read: true });
}
}
@bind
onNewMessage(message) {
this.paneState.handleIncomingMessage({
scroller: this.scroller,
shouldAutoScroll: this.paneState.userIsPresent && this.atBottom,
addMessage: () => this.messagesManager.addMessages([message]),
onAutoAdd: () => this.debouncedUpdateLastReadMessage(),
});
}
async fetchMessages(findArgs = {}) {
if (this.messagesLoader.loading) {
return;
}
this.messagesManager.clear();
const result = await this.messagesLoader.load(findArgs);
this.messagesManager.messages = this.processMessages(
this.args.channel,
result
);
if (findArgs.target_message_id) {
this.scrollToMessageId(findArgs.target_message_id, {
highlight: true,
position: findArgs.position,
});
} else if (findArgs.fetch_from_last_read) {
const lastReadMessageId = this.currentUserMembership?.lastReadMessageId;
this.scrollToMessageId(lastReadMessageId);
} else if (findArgs.target_date) {
this.scrollToMessageId(result.meta.target_message_id, {
highlight: true,
position: "center",
});
} else {
this._ignoreNextScroll = true;
this.scrollToBottom();
}
this.debounceFillPaneAttempt();
this.debouncedUpdateLastReadMessage();
}
async fetchMoreMessages({ direction }, opts = {}) {
if (this.messagesLoader.loading) {
return;
}
const result = await this.messagesLoader.loadMore({ direction });
if (!result) {
return;
}
const messages = this.processMessages(this.args.channel, result);
if (!messages.length) {
return;
}
const targetMessageId = this.messagesManager.messages.at(-1).id;
this.messagesManager.addMessages(messages);
if (direction === FUTURE && !opts.noScroll) {
this.scrollToMessageId(targetMessageId, {
position: "end",
forceAuto: true,
});
}
this.debounceFillPaneAttempt();
}
@action
async scrollToBottom() {
this._ignoreNextScroll = true;
await scrollListToBottom(this.scroller);
if (this.paneState.userIsPresent) {
this.debouncedUpdateLastReadMessage();
}
this.paneState.clearPendingMessages();
}
scrollToMessageId(messageId, options = {}) {
this._ignoreNextScroll = true;
const message = this.messagesManager.findMessage(messageId);
scrollListToMessage(this.scroller, message, options);
}
debounceFillPaneAttempt() {
this._debouncedFillPaneAttemptHandler = discourseDebounce(
this,
this.fillPaneAttempt,
500
);
}
@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" });
}
}
async fillPaneAttempt() {
if (!this.messagesLoader.fetchedOnce) {
return;
}
// safeguard
if (this.messagesManager.messages.length > 200) {
return;
}
if (!this.messagesLoader.canLoadMorePast) {
return;
}
schedule("afterRender", () => {
const firstMessageId = this.messagesManager.messages[0]?.id;
const messageContainer = this.scroller.querySelector(
`.chat-message-container[data-id="${firstMessageId}"]`
);
if (
messageContainer &&
checkMessageTopVisibility(this.scroller, messageContainer)
) {
this.fetchMoreMessages({ direction: PAST });
}
});
}
@bind
processMessages(channel, result) {
const messages = [];
let foundFirstNew = false;
channel.newestMessage = null;
result?.messages?.forEach((messageData, index) => {
messageData.firstOfResults = index === 0;
if (this.currentUser.ignored_users) {
// 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
messageData.hidden = this.currentUser.ignored_users.includes(
messageData.user.username
);
}
if (this.requestedTargetMessageId === messageData.id) {
messageData.expanded = !messageData.hidden;
} else {
messageData.expanded = !(messageData.hidden || messageData.deleted_at);
}
const message = ChatMessage.create(channel, messageData);
message.manager = channel.messagesManager;
// newest has to be in after fetch callback as we don't want to make it
// dynamic or it will make the pane jump around, it will disappear on reload
if (
!foundFirstNew &&
messageData.id > this.currentUserMembership?.lastReadMessageId
) {
foundFirstNew = true;
if (message !== channel.lastMessage) {
channel.newestMessage = message;
} else {
channel.newestMessage = null;
}
}
if (message.thread) {
this.#preloadThreadTrackingState(
message.thread,
result.tracking.thread_tracking
);
}
messages.push(message);
});
return messages;
}
debounceHighlightOrFetchMessage(messageId, options = {}) {
this._debouncedHighlightOrFetchMessageHandler = discourseDebounce(
this,
this.highlightOrFetchMessage,
messageId,
options,
100
);
}
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, position: "end" });
}
}
@bind
debouncedUpdateLastReadMessage() {
this._debouncedUpdateLastReadMessageHandler = discourseDebounce(
this,
this.updateLastReadMessage,
READ_INTERVAL_MS
);
}
updateLastReadMessage() {
if (!this.paneState.userIsPresent) {
return;
}
if (!this.args.channel.isFollowing) {
return;
}
const firstFullyVisibleMessageId = firstVisibleMessageId(this.scroller);
if (!firstFullyVisibleMessageId) {
return;
}
let firstMessage = this.messagesManager.findMessage(
firstFullyVisibleMessageId
);
if (!firstMessage) {
return;
}
const lastReadId =
this.args.channel.currentUserMembership?.lastReadMessageId;
if (lastReadId >= firstMessage.id) {
return;
}
// optimistic update
this.args.channel.currentUserMembership.lastReadMessageId = firstMessage.id;
this.args.channel.updateLastViewedAt();
return this.chatApi.markChannelAsRead(
this.args.channel.id,
firstMessage.id
);
}
@action
scrollToLatestMessage() {
if (this.messagesLoader.canLoadMoreFuture) {
this.fetchMessages();
} else if (this.messagesManager.messages.length > 0) {
this.scrollToBottom();
}
}
@action
onScroll(state) {
next(() => {
if (this.#flushIgnoreNextScroll()) {
return;
}
DatesSeparatorsPositioner.apply(this.scroller);
this.paneState.updatePendingContentFromScrollState({
scroller: this.scroller,
fetchedOnce: this.messagesLoader.fetchedOnce,
canLoadMoreFuture: this.messagesLoader.canLoadMoreFuture,
state,
});
this.isScrolling = true;
this.debouncedUpdateLastReadMessage();
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.isScrolling = false;
this.atBottom = state.atBottom;
if (state.atBottom) {
if (this.paneState.userIsPresent) {
this.paneState.clearPendingMessages();
}
this.fetchMoreMessages({ direction: FUTURE });
this.chatChannelScrollPositions.delete(this.args.channel.id);
} else {
this.paneState.updatePendingContentFromScrollState({
scroller: this.scroller,
fetchedOnce: this.messagesLoader.fetchedOnce,
canLoadMoreFuture: this.messagesLoader.canLoadMoreFuture,
state,
});
this.chatChannelScrollPositions.set(
this.args.channel.id,
state.firstVisibleId
);
}
}
@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);
}
}
@action
resetComposerMessage() {
this.args.channel.resetDraft(this.currentUser);
}
async #sendEditMessage(message) {
this.pane.sending = true;
const data = {
message: message.message,
upload_ids: message.uploads.map((upload) => upload.id),
};
this.resetComposerMessage();
try {
await this.chatApi.editMessage(this.args.channel.id, message.id, data);
} catch (e) {
popupAjaxError(e);
} finally {
message.editing = false;
this.pane.sending = false;
}
}
async #sendNewMessage(message) {
this.pane.sending = true;
await this.args.channel.stageMessage(message);
message.manager = this.args.channel.messagesManager;
this.resetComposerMessage();
if (!this.messagesLoader.canLoadMoreFuture) {
this.scrollToLatestMessage();
}
try {
await this.chatApi.sendMessage(this.args.channel.id, {
message: message.message,
in_reply_to_id: message.inReplyTo?.id,
staged_id: message.id,
upload_ids: message.uploads.map((upload) => upload.id),
client_created_at: message.createdAt.toISOString(),
...extractCurrentTopicInfo(this),
});
this.scrollToLatestMessage();
} catch (error) {
this._onSendError(message.id, error);
} finally {
this.pane.sending = false;
}
}
_onSendError(id, error) {
const stagedMessage =
this.args.channel.messagesManager.findStagedMessage(id);
if (stagedMessage) {
if (error.jqXHR?.responseJSON?.errors?.length) {
// only network errors are retryable
stagedMessage.message = "";
stagedMessage.cooked = "";
stagedMessage.error = error.jqXHR.responseJSON.errors[0];
} else {
stagedMessage.error = "network_error";
}
}
this.resetComposerMessage();
}
@action
resendStagedMessage(stagedMessage) {
this.pane.sending = true;
stagedMessage.error = null;
const data = {
cooked: stagedMessage.cooked,
message: stagedMessage.message,
upload_ids: stagedMessage.uploads.map((upload) => upload.id),
staged_id: stagedMessage.id,
};
this.chatApi
.sendMessage(this.args.channel.id, data)
.catch((error) => {
this._onSendError(data.staged_id, error);
})
.finally(() => {
this.pane.sending = false;
});
}
@action
onCloseFullScreen() {
this.chatStateManager.prefersDrawer();
DiscourseURL.routeTo(this.chatStateManager.lastKnownAppURL).then(() => {
DiscourseURL.routeTo(this.chatStateManager.lastKnownChatURL);
});
}
@bind
_autoFocus(event) {
if (this.chatStateManager.isDrawerActive) {
return;
}
const { key, metaKey, ctrlKey, code, target } = event;
if (
!key ||
// Handles things like Enter, Tab, Shift
key.length > 1 ||
// Don't need to focus if the user is beginning a shortcut.
metaKey ||
ctrlKey ||
// Space's key comes through as ' ' so it's not covered by key
code === "Space" ||
// ? is used for the keyboard shortcut modal
key === "?"
) {
return;
}
if (
!target ||
/^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName) ||
target.closest('[contenteditable="true"]')
) {
return;
}
event.preventDefault();
this.composer.focus({ addText: event.key });
return;
}
#cancelHandlers() {
cancel(this._debouncedHighlightOrFetchMessageHandler);
cancel(this._debouncedUpdateLastReadMessageHandler);
cancel(this._debouncedFillPaneAttemptHandler);
}
#preloadThreadTrackingState(thread, threadTracking) {
if (!threadTracking[thread.id]) {
return;
}
thread.tracking.unreadCount = threadTracking[thread.id].unread_count;
thread.tracking.mentionCount = threadTracking[thread.id].mention_count;
thread.tracking.watchedThreadsUnreadCount =
threadTracking[thread.id].watched_threads_unread_count;
}
#flushIgnoreNextScroll() {
const prev = this._ignoreNextScroll;
this._ignoreNextScroll = false;
return prev;
}
<template>
<div
class={{dConcatClass
"chat-channel"
(if this.messagesLoader.loading "loading")
(if this.pane.sending "chat-channel--sending")
(if this.hasSavedScrollPosition "chat-channel--saved-scroll-position")
(if this.messagesLoader.fetchedOnce "--loaded")
}}
{{willDestroy this.teardown}}
{{didInsert this.setup}}
{{didUpdate this.loadMessages @targetMessageId}}
data-id={{@channel.id}}
>
<ChatChannelStatus @channel={{@channel}} />
<ChatNotices @channel={{@channel}} />
<ChatMentionWarnings />
<ChatChannelFilter
@isFiltering={{@isFiltering}}
@onToggleFilter={{@onToggleFilter}}
@channel={{@channel}}
@onLoadTargetMessageId={{this.onLoadTargetMessageId}}
/>
<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="channel"
/>
{{else}}
{{#unless this.messagesLoader.fetchedOnce}}
<ChatSkeleton />
{{/unless}}
{{/each}}
</ChatMessagesContainer>
{{! at bottom even if shown at top due to column-reverse }}
{{#if this.messagesLoader.loadedPast}}
<div class="all-loaded-message">
{{i18n "chat.all_loaded"}}
</div>
{{/if}}
</ChatMessagesScroller>
<ChatScrollToBottomArrow
@onScrollToBottom={{this.scrollToLatestMessage}}
@isVisible={{this.paneState.hasPendingContentBelow}}
@channel={{@channel}}
/>
{{#if this.pane.selectingMessages}}
<ChatSelectionManager
@enableMove={{and
(not @channel.isDirectMessageChannel)
@channel.canModerate
}}
@pane={{this.pane}}
@messagesManager={{this.messagesManager}}
/>
{{else}}
{{#if (and (not @channel.isFollowing) @channel.isCategoryChannel)}}
<ChatChannelPreviewCard @channel={{@channel}} />
{{else}}
<ChatComposerChannel
@channel={{@channel}}
@uploadDropZone={{this.uploadDropZone}}
@onSendMessage={{this.onSendMessage}}
@scroller={{this.scroller}}
/>
{{/if}}
{{/if}}
<ChatUploadDropZone @model={{@channel}} />
</div>
</template>
}