mirror of
https://gh.wpcy.net/https://github.com/discourse/discourse.git
synced 2026-05-24 02:41:20 +08:00
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/`.
788 lines
22 KiB
Text
Vendored
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>
|
|
}
|