mirror of
https://github.com/discourse/discourse.git
synced 2025-02-25 18:55:32 -06:00
This fix uses direct `scrollTop` manipulation instead of `scrollIntoView` when we are certain we actually want the bottom of the screen. This avoids a range of issues especially in safari but also chrome where the scroll position was not correct at the end of `scrollIntoView`, especially due to images.
1321 lines
34 KiB
JavaScript
1321 lines
34 KiB
JavaScript
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 discourseDebounce from "discourse-common/lib/debounce";
|
||
import EmberObject, { action } from "@ember/object";
|
||
import { ajax } from "discourse/lib/ajax";
|
||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||
import { cancel, schedule } from "@ember/runloop";
|
||
import discourseLater from "discourse-common/lib/later";
|
||
import { inject as service } from "@ember/service";
|
||
import { Promise } from "rsvp";
|
||
import { resetIdle } from "discourse/lib/desktop-notifications";
|
||
import {
|
||
onPresenceChange,
|
||
removeOnPresenceChange,
|
||
} from "discourse/lib/user-presence";
|
||
import isZoomed from "discourse/plugins/chat/discourse/lib/zoom-check";
|
||
import { tracked } from "@glimmer/tracking";
|
||
import { getOwner } from "discourse-common/lib/get-owner";
|
||
|
||
const PAGE_SIZE = 50;
|
||
const PAST = "past";
|
||
const FUTURE = "future";
|
||
const READ_INTERVAL_MS = 1000;
|
||
|
||
export default class ChatLivePane extends Component {
|
||
@service chat;
|
||
@service chatChannelsManager;
|
||
@service router;
|
||
@service chatEmojiPickerManager;
|
||
@service chatComposerPresenceManager;
|
||
@service chatStateManager;
|
||
@service chatApi;
|
||
@service currentUser;
|
||
@service appEvents;
|
||
@service messageBus;
|
||
@service site;
|
||
|
||
@tracked loading = false;
|
||
@tracked loadingMorePast = false;
|
||
@tracked loadingMoreFuture = false;
|
||
@tracked hoveredMessageId = null;
|
||
@tracked sendingLoading = false;
|
||
@tracked selectingMessages = false;
|
||
@tracked showChatQuoteSuccess = false;
|
||
@tracked includeHeader = true;
|
||
@tracked editingMessage = null;
|
||
@tracked replyToMsg = null;
|
||
@tracked hasNewMessages = false;
|
||
@tracked needsArrow = false;
|
||
@tracked loadedOnce = false;
|
||
|
||
_loadedChannelId = null;
|
||
_scrollerEl = null;
|
||
_lastSelectedMessage = null;
|
||
_mentionWarningsSeen = {};
|
||
_unreachableGroupMentions = [];
|
||
_overMembersLimitGroupMentions = [];
|
||
|
||
@action
|
||
setupListeners(element) {
|
||
this._scrollerEl = element.querySelector(".chat-messages-scroll");
|
||
|
||
window.addEventListener("resize", this.onResizeHandler);
|
||
document.addEventListener("scroll", this._forceBodyScroll, {
|
||
passive: true,
|
||
});
|
||
|
||
onPresenceChange({
|
||
callback: this.onPresenceChangeCallback,
|
||
});
|
||
}
|
||
|
||
@action
|
||
teardownListeners() {
|
||
window.removeEventListener("resize", this.onResizeHandler);
|
||
cancel(this.resizeHandler);
|
||
document.removeEventListener("scroll", this._forceBodyScroll);
|
||
removeOnPresenceChange(this.onPresenceChangeCallback);
|
||
this._unsubscribeToUpdates(this._loadedChannelId);
|
||
this.requestedTargetMessageId = null;
|
||
}
|
||
|
||
@action
|
||
resetIdle() {
|
||
resetIdle();
|
||
}
|
||
|
||
@action
|
||
updateChannel() {
|
||
this.loadedOnce = false;
|
||
|
||
// Technically we could keep messages to avoid re-fetching them, but
|
||
// it's not worth the complexity for now
|
||
this.args.channel?.clearMessages();
|
||
|
||
if (this._loadedChannelId !== this.args.channel?.id) {
|
||
this._unsubscribeToUpdates(this._loadedChannelId);
|
||
this.selectingMessages = false;
|
||
this.cancelEditing();
|
||
this._loadedChannelId = this.args.channel?.id;
|
||
}
|
||
|
||
this.loadMessages();
|
||
this._subscribeToUpdates(this.args.channel?.id);
|
||
}
|
||
|
||
@action
|
||
loadMessages() {
|
||
if (!this.args.channel?.id) {
|
||
this.loadedOnce = true;
|
||
return;
|
||
}
|
||
|
||
if (this.args.targetMessageId) {
|
||
this.requestedTargetMessageId = parseInt(this.args.targetMessageId, 10);
|
||
}
|
||
|
||
if (this.requestedTargetMessageId) {
|
||
this.highlightOrFetchMessage(this.requestedTargetMessageId);
|
||
} else {
|
||
this.fetchMessages({ fetchFromLastMessage: false });
|
||
}
|
||
}
|
||
|
||
@bind
|
||
onResizeHandler() {
|
||
cancel(this.resizeHandler);
|
||
this.resizeHandler = discourseDebounce(
|
||
this,
|
||
this.fillPaneAttempt,
|
||
this.details,
|
||
250
|
||
);
|
||
}
|
||
|
||
@bind
|
||
onPresenceChangeCallback(present) {
|
||
if (present) {
|
||
this.updateLastReadMessage();
|
||
}
|
||
}
|
||
|
||
get capabilities() {
|
||
return getOwner(this).lookup("capabilities:main");
|
||
}
|
||
|
||
@debounce(100)
|
||
fetchMessages(options = {}) {
|
||
if (this._selfDeleted) {
|
||
return;
|
||
}
|
||
|
||
this.loadingMorePast = true;
|
||
|
||
const findArgs = { pageSize: PAGE_SIZE };
|
||
const fetchingFromLastRead = !options.fetchFromLastMessage;
|
||
if (this.requestedTargetMessageId) {
|
||
findArgs["targetMessageId"] = this.requestedTargetMessageId;
|
||
} else if (fetchingFromLastRead) {
|
||
findArgs["targetMessageId"] =
|
||
this.args.channel.currentUserMembership.last_read_message_id;
|
||
}
|
||
|
||
return this.chatApi
|
||
.messages(this.args.channel.id, findArgs)
|
||
.then((results) => {
|
||
if (
|
||
this._selfDeleted ||
|
||
this.args.channel.id !== results.meta.channel_id
|
||
) {
|
||
return;
|
||
}
|
||
|
||
const [messages, meta] = this.afterFetchCallback(
|
||
this.args.channel,
|
||
results
|
||
);
|
||
|
||
this.args.channel.messages = messages;
|
||
this.args.channel.details = meta;
|
||
|
||
if (this.requestedTargetMessageId) {
|
||
this.scrollToMessage(findArgs["targetMessageId"], {
|
||
highlight: true,
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (
|
||
fetchingFromLastRead &&
|
||
messages.length &&
|
||
findArgs["targetMessageId"] !== messages[messages.length - 1].id
|
||
) {
|
||
this.scrollToMessage(findArgs["targetMessageId"]);
|
||
return;
|
||
}
|
||
|
||
this.scrollToBottom();
|
||
})
|
||
.catch(this._handleErrors)
|
||
.finally(() => {
|
||
if (this._selfDeleted) {
|
||
return;
|
||
}
|
||
|
||
this.loadedOnce = true;
|
||
this.requestedTargetMessageId = null;
|
||
this.loadingMorePast = false;
|
||
this.fillPaneAttempt();
|
||
this.updateLastReadMessage();
|
||
});
|
||
}
|
||
|
||
@bind
|
||
fetchMoreMessages({ direction }) {
|
||
const loadingPast = direction === PAST;
|
||
const loadingMoreKey = `loadingMore${capitalize(direction)}`;
|
||
|
||
const canLoadMore = loadingPast
|
||
? this.args.channel.canLoadMorePast
|
||
: this.args.channel.canLoadMoreFuture;
|
||
|
||
if (
|
||
!canLoadMore ||
|
||
this.loading ||
|
||
this[loadingMoreKey] ||
|
||
!this.args.channel.messages?.length > 0
|
||
) {
|
||
return Promise.resolve();
|
||
}
|
||
|
||
this[loadingMoreKey] = true;
|
||
|
||
const messageIndex = loadingPast
|
||
? 0
|
||
: this.args.channel.messages.length - 1;
|
||
const messageId = this.args.channel.messages[messageIndex].id;
|
||
const findArgs = {
|
||
channelId: this.args.channel.id,
|
||
pageSize: PAGE_SIZE,
|
||
direction,
|
||
messageId,
|
||
};
|
||
|
||
return this.chatApi
|
||
.messages(this.args.channel.id, findArgs)
|
||
.then((results) => {
|
||
if (
|
||
this._selfDeleted ||
|
||
this.args.channel.id !== results.meta.channel_id
|
||
) {
|
||
return;
|
||
}
|
||
|
||
// prevents an edge case where user clicks bottom arrow
|
||
// just after scrolling to top
|
||
if (loadingPast && this.#isAtBottom()) {
|
||
return;
|
||
}
|
||
|
||
const [messages, meta] = this.afterFetchCallback(
|
||
this.args.channel,
|
||
results
|
||
);
|
||
|
||
if (!messages?.length) {
|
||
return;
|
||
}
|
||
|
||
this.args.channel.details = meta;
|
||
this.args.channel.addMessages(messages);
|
||
|
||
// Edge case for IOS to avoid blank screens
|
||
// and/or scrolling to bottom losing track of scroll position
|
||
if (!loadingPast && (this.capabilities.isIOS || !this.isScrolling)) {
|
||
this.scrollToMessage(messages[0].id, { position: "end" });
|
||
}
|
||
})
|
||
.catch(() => {
|
||
this._handleErrors();
|
||
})
|
||
.finally(() => {
|
||
this[loadingMoreKey] = false;
|
||
this.fillPaneAttempt();
|
||
this.computeDatesSeparators();
|
||
});
|
||
}
|
||
|
||
@debounce(500)
|
||
fillPaneAttempt() {
|
||
if (this._selfDeleted) {
|
||
return;
|
||
}
|
||
|
||
// safeguard
|
||
if (this.args.channel.messages?.length > 200) {
|
||
return;
|
||
}
|
||
|
||
if (!this.args.channel?.canLoadMorePast) {
|
||
return;
|
||
}
|
||
|
||
const firstMessage = this.args.channel?.messages?.[0];
|
||
if (!firstMessage?.visible) {
|
||
return;
|
||
}
|
||
|
||
this.fetchMoreMessages({ direction: PAST });
|
||
}
|
||
|
||
@bind
|
||
afterFetchCallback(channel, results) {
|
||
const messages = [];
|
||
let foundFirstNew = false;
|
||
|
||
results.chat_messages.forEach((messageData, index) => {
|
||
if (index === 0) {
|
||
messageData.firstOfResults = true;
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
// newest has to be in after fetcg 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.args.channel.currentUserMembership.last_read_message_id &&
|
||
!channel.messages.some((m) => m.newest)
|
||
) {
|
||
foundFirstNew = true;
|
||
messageData.newest = true;
|
||
}
|
||
|
||
messages.push(ChatMessage.create(channel, messageData));
|
||
});
|
||
|
||
return [messages, results.meta];
|
||
}
|
||
|
||
@debounce(100)
|
||
highlightOrFetchMessage(messageId) {
|
||
const message = this.args.channel.findMessage(messageId);
|
||
if (message) {
|
||
this.scrollToMessage(message.id, {
|
||
highlight: true,
|
||
position: "start",
|
||
autoExpand: true,
|
||
});
|
||
this.requestedTargetMessageId = null;
|
||
} else {
|
||
this.fetchMessages();
|
||
}
|
||
}
|
||
|
||
scrollToMessage(
|
||
messageId,
|
||
opts = { highlight: false, position: "start", autoExpand: false }
|
||
) {
|
||
if (this._selfDeleted) {
|
||
return;
|
||
}
|
||
|
||
const message = this.args.channel.findMessage(messageId);
|
||
if (message?.deletedAt && opts.autoExpand) {
|
||
message.expanded = true;
|
||
}
|
||
|
||
schedule("afterRender", () => {
|
||
const messageEl = this._scrollerEl.querySelector(
|
||
`.chat-message-container[data-id='${messageId}']`
|
||
);
|
||
|
||
if (!messageEl || this._selfDeleted) {
|
||
return;
|
||
}
|
||
|
||
if (opts.highlight) {
|
||
message.highlighted = true;
|
||
|
||
discourseLater(() => {
|
||
if (this._selfDeleted) {
|
||
return;
|
||
}
|
||
|
||
message.highlighted = false;
|
||
}, 2000);
|
||
}
|
||
|
||
this.forceRendering(() => {
|
||
messageEl.scrollIntoView({
|
||
block: opts.position ?? "center",
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
@action
|
||
messageDidEnterViewport(message) {
|
||
message.visible = true;
|
||
}
|
||
|
||
@action
|
||
messageDidLeaveViewport(message) {
|
||
message.visible = false;
|
||
}
|
||
|
||
@debounce(READ_INTERVAL_MS)
|
||
updateLastReadMessage() {
|
||
schedule("afterRender", () => {
|
||
if (this._selfDeleted) {
|
||
return;
|
||
}
|
||
|
||
const lastReadId =
|
||
this.args.channel.currentUserMembership?.last_read_message_id;
|
||
let lastUnreadVisibleMessage = this.args.channel.visibleMessages.findLast(
|
||
(message) => !lastReadId || message.id > lastReadId
|
||
);
|
||
|
||
// all intersecting messages are read
|
||
if (!lastUnreadVisibleMessage) {
|
||
return;
|
||
}
|
||
|
||
const element = this._scrollerEl.querySelector(
|
||
`[data-id='${lastUnreadVisibleMessage.id}']`
|
||
);
|
||
|
||
// if the last visible message is not fully visible, we don't want to mark it as read
|
||
// attempt to mark previous one as read
|
||
if (!this.#isBottomOfMessageVisible(element, this._scrollerEl)) {
|
||
lastUnreadVisibleMessage = lastUnreadVisibleMessage.previousMessage;
|
||
|
||
if (
|
||
!lastUnreadVisibleMessage ||
|
||
lastReadId > lastUnreadVisibleMessage.id
|
||
) {
|
||
return;
|
||
}
|
||
}
|
||
|
||
this.args.channel.updateLastReadMessage(lastUnreadVisibleMessage.id);
|
||
});
|
||
}
|
||
|
||
@action
|
||
scrollToBottom() {
|
||
schedule("afterRender", () => {
|
||
if (this._selfDeleted) {
|
||
return;
|
||
}
|
||
|
||
// 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
|
||
this._scrollerEl.scrollTop = -1;
|
||
this.forceRendering(() => {
|
||
this._scrollerEl.scrollTop = 0;
|
||
});
|
||
});
|
||
}
|
||
|
||
@action
|
||
scrollToLatestMessage() {
|
||
schedule("afterRender", () => {
|
||
if (this._selfDeleted) {
|
||
return;
|
||
}
|
||
|
||
if (this.args.channel.canLoadMoreFuture) {
|
||
this._fetchAndScrollToLatest();
|
||
} else if (this.args.channel.messages?.length > 0) {
|
||
this.scrollToMessage(
|
||
this.args.channel.messages[this.args.channel.messages.length - 1].id
|
||
);
|
||
}
|
||
});
|
||
}
|
||
|
||
@action
|
||
computeArrow() {
|
||
this.needsArrow = Math.abs(this._scrollerEl.scrollTop) >= 100;
|
||
}
|
||
|
||
@action
|
||
computeScrollState() {
|
||
cancel(this.onScrollEndedHandler);
|
||
|
||
if (this.#isAtTop()) {
|
||
this.fetchMoreMessages({ direction: PAST });
|
||
this.onScrollEnded();
|
||
} else if (this.#isAtBottom()) {
|
||
this.updateLastReadMessage();
|
||
this.hasNewMessages = false;
|
||
this.fetchMoreMessages({ direction: FUTURE });
|
||
this.onScrollEnded();
|
||
} else {
|
||
this.isScrolling = true;
|
||
this.onScrollEndedHandler = discourseLater(this, this.onScrollEnded, 150);
|
||
}
|
||
}
|
||
|
||
@bind
|
||
onScrollEnded() {
|
||
this.isScrolling = false;
|
||
}
|
||
|
||
removeMessage(msgData) {
|
||
const message = this.args.channel.findMessage(msgData.id);
|
||
if (message) {
|
||
this.args.channel.removeMessage(message);
|
||
}
|
||
}
|
||
|
||
@bind
|
||
onMessage(data) {
|
||
switch (data.type) {
|
||
case "sent":
|
||
this.handleSentMessage(data);
|
||
break;
|
||
case "processed":
|
||
this.handleProcessedMessage(data);
|
||
break;
|
||
case "edit":
|
||
this.handleEditMessage(data);
|
||
break;
|
||
case "refresh":
|
||
this.handleRefreshMessage(data);
|
||
break;
|
||
case "delete":
|
||
this.handleDeleteMessage(data);
|
||
break;
|
||
case "bulk_delete":
|
||
this.handleBulkDeleteMessage(data);
|
||
break;
|
||
case "reaction":
|
||
this.handleReactionMessage(data);
|
||
break;
|
||
case "restore":
|
||
this.handleRestoreMessage(data);
|
||
break;
|
||
case "mention_warning":
|
||
this.handleMentionWarning(data);
|
||
break;
|
||
case "self_flagged":
|
||
this.handleSelfFlaggedMessage(data);
|
||
break;
|
||
case "flag":
|
||
this.handleFlaggedMessage(data);
|
||
break;
|
||
}
|
||
}
|
||
|
||
_handleStagedMessage(stagedMessage, data) {
|
||
stagedMessage.error = null;
|
||
stagedMessage.id = data.chat_message.id;
|
||
stagedMessage.staged = false;
|
||
stagedMessage.excerpt = data.chat_message.excerpt;
|
||
stagedMessage.threadId = data.chat_message.thread_id;
|
||
stagedMessage.channelId = data.chat_message.chat_channel_id;
|
||
stagedMessage.createdAt = data.chat_message.created_at;
|
||
|
||
const inReplyToMsg = this.args.channel.findMessage(
|
||
data.chat_message.in_reply_to?.id
|
||
);
|
||
if (inReplyToMsg && !inReplyToMsg.threadId) {
|
||
inReplyToMsg.threadId = data.chat_message.thread_id;
|
||
}
|
||
|
||
// some markdown is cooked differently on the server-side, e.g.
|
||
// quotes, avatar images etc.
|
||
if (data.chat_message?.cooked !== stagedMessage.cooked) {
|
||
stagedMessage.cooked = data.chat_message.cooked;
|
||
}
|
||
}
|
||
|
||
handleSentMessage(data) {
|
||
if (this.args.channel.isFollowing) {
|
||
this.args.channel.lastMessageSentAt = new Date();
|
||
}
|
||
|
||
if (data.chat_message.user.id === this.currentUser.id && data.staged_id) {
|
||
const stagedMessage = this.args.channel.findStagedMessage(data.staged_id);
|
||
if (stagedMessage) {
|
||
return this._handleStagedMessage(stagedMessage, data);
|
||
}
|
||
}
|
||
|
||
if (this.args.channel.canLoadMoreFuture) {
|
||
// If we can load more messages, we just notice the user of new messages
|
||
this.hasNewMessages = true;
|
||
} else if (this.#isTowardsBottom()) {
|
||
// If we are at the bottom, we append the message and scroll to it
|
||
const message = ChatMessage.create(this.args.channel, data.chat_message);
|
||
this.args.channel.addMessages([message]);
|
||
this.scrollToLatestMessage();
|
||
} else {
|
||
// If we are almost at the bottom, we append the message and notice the user
|
||
const message = ChatMessage.create(this.args.channel, data.chat_message);
|
||
this.args.channel.addMessages([message]);
|
||
this.hasNewMessages = true;
|
||
}
|
||
}
|
||
|
||
handleProcessedMessage(data) {
|
||
const message = this.args.channel.findMessage(data.chat_message.id);
|
||
if (message) {
|
||
message.cooked = data.chat_message.cooked;
|
||
this.scrollToLatestMessage();
|
||
}
|
||
}
|
||
|
||
handleRefreshMessage(data) {
|
||
const message = this.args.channel.findMessage(data.chat_message.id);
|
||
if (message) {
|
||
message.version = message.version + 1;
|
||
}
|
||
}
|
||
|
||
handleEditMessage(data) {
|
||
const message = this.args.channel.findMessage(data.chat_message.id);
|
||
if (message) {
|
||
message.message = data.chat_message.message;
|
||
message.cooked = data.chat_message.cooked;
|
||
message.excerpt = data.chat_message.excerpt;
|
||
message.uploads = cloneJSON(data.chat_message.uploads || []);
|
||
message.edited = true;
|
||
}
|
||
}
|
||
|
||
handleBulkDeleteMessage(data) {
|
||
data.deleted_ids.forEach((deletedId) => {
|
||
this.handleDeleteMessage({
|
||
deleted_id: deletedId,
|
||
deleted_at: data.deleted_at,
|
||
});
|
||
});
|
||
}
|
||
|
||
handleDeleteMessage(data) {
|
||
const deletedId = data.deleted_id;
|
||
const targetMsg = this.args.channel.findMessage(deletedId);
|
||
|
||
if (!targetMsg) {
|
||
return;
|
||
}
|
||
|
||
if (this.currentUser.staff || this.currentUser.id === targetMsg.user.id) {
|
||
targetMsg.deletedAt = data.deleted_at;
|
||
targetMsg.expanded = false;
|
||
} else {
|
||
this.args.channel.removeMessage(targetMsg);
|
||
}
|
||
}
|
||
|
||
handleReactionMessage(data) {
|
||
if (data.user.id !== this.currentUser.id) {
|
||
const message = this.args.channel.findMessage(data.chat_message_id);
|
||
if (message) {
|
||
message.react(data.emoji, data.action, data.user, this.currentUser.id);
|
||
}
|
||
}
|
||
}
|
||
|
||
handleRestoreMessage(data) {
|
||
const message = this.args.channel.findMessage(data.chat_message.id);
|
||
if (message) {
|
||
message.deletedAt = null;
|
||
} else {
|
||
this.args.channel.addMessages([
|
||
ChatMessage.create(this.args.channel, data.chat_message),
|
||
]);
|
||
}
|
||
}
|
||
|
||
handleMentionWarning(data) {
|
||
const message = this.args.channel.findMessage(data.chat_message_id);
|
||
if (message) {
|
||
message.mentionWarning = EmberObject.create(data);
|
||
}
|
||
}
|
||
|
||
handleSelfFlaggedMessage(data) {
|
||
const message = this.args.channel.findMessage(data.chat_message_id);
|
||
if (message) {
|
||
message.userFlagStatus = data.user_flag_status;
|
||
}
|
||
}
|
||
|
||
handleFlaggedMessage(data) {
|
||
const message = this.args.channel.findMessage(data.chat_message_id);
|
||
if (message) {
|
||
message.reviewableId = data.reviewable_id;
|
||
}
|
||
}
|
||
|
||
get _selfDeleted() {
|
||
return this.isDestroying || this.isDestroyed;
|
||
}
|
||
|
||
@action
|
||
sendMessage(message, uploads = []) {
|
||
resetIdle();
|
||
|
||
if (this.sendingLoading) {
|
||
return;
|
||
}
|
||
|
||
this.sendingLoading = true;
|
||
this.args.channel.draft = ChatMessageDraft.create();
|
||
|
||
// TODO: all send message logic is due for massive refactoring
|
||
// This is all the possible case Im currently aware of
|
||
// - messaging to a public channel where you are not a member yet (preview = true)
|
||
// - messaging to an existing direct channel you were not tracking yet through dm creator (channel draft)
|
||
// - messaging to a new direct channel through DM creator (channel draft)
|
||
// - message to a direct channel you were tracking (preview = false, not draft)
|
||
// - 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;
|
||
|
||
return this._upsertChannelWithMessage(
|
||
this.args.channel,
|
||
message,
|
||
uploads
|
||
).finally(() => {
|
||
if (this._selfDeleted) {
|
||
return;
|
||
}
|
||
this.loading = false;
|
||
this.sendingLoading = false;
|
||
this._resetAfterSend();
|
||
this.scrollToLatestMessage();
|
||
});
|
||
}
|
||
|
||
const stagedMessage = ChatMessage.createStagedMessage(this.args.channel, {
|
||
message,
|
||
created_at: new Date(),
|
||
uploads: cloneJSON(uploads),
|
||
user: this.currentUser,
|
||
});
|
||
|
||
if (this.replyToMsg) {
|
||
stagedMessage.inReplyTo = this.replyToMsg;
|
||
}
|
||
|
||
this.args.channel.addMessages([stagedMessage]);
|
||
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),
|
||
})
|
||
.then(() => {
|
||
this.scrollToLatestMessage();
|
||
})
|
||
.catch((error) => {
|
||
this._onSendError(stagedMessage.id, error);
|
||
})
|
||
.finally(() => {
|
||
if (this._selfDeleted) {
|
||
return;
|
||
}
|
||
this.sendingLoading = false;
|
||
this._resetAfterSend();
|
||
});
|
||
}
|
||
|
||
async _upsertChannelWithMessage(channel, message, uploads) {
|
||
let promise = Promise.resolve(channel);
|
||
|
||
if (channel.isDirectMessageChannel || channel.isDraft) {
|
||
promise = this.chat.upsertDmChannelForUsernames(
|
||
channel.chatable.users.mapBy("username")
|
||
);
|
||
}
|
||
|
||
return promise.then((c) =>
|
||
ajax(`/chat/${c.id}.json`, {
|
||
type: "POST",
|
||
data: {
|
||
message,
|
||
upload_ids: (uploads || []).mapBy("id"),
|
||
},
|
||
}).then(() => {
|
||
this.router.transitionTo("chat.channel", "-", c.id);
|
||
})
|
||
);
|
||
}
|
||
|
||
_onSendError(id, error) {
|
||
const stagedMessage = this.args.channel.findStagedMessage(id);
|
||
if (stagedMessage) {
|
||
if (error.jqXHR?.responseJSON?.errors?.length) {
|
||
stagedMessage.error = error.jqXHR.responseJSON.errors[0];
|
||
} else {
|
||
this.chat.markNetworkAsUnreliable();
|
||
stagedMessage.error = "network_error";
|
||
}
|
||
}
|
||
|
||
this._resetAfterSend();
|
||
}
|
||
|
||
@action
|
||
resendStagedMessage(stagedMessage) {
|
||
this.sendingLoading = 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);
|
||
})
|
||
.then(() => {
|
||
this.chat.markNetworkAsReliable();
|
||
})
|
||
.finally(() => {
|
||
if (this._selfDeleted) {
|
||
return;
|
||
}
|
||
this.sendingLoading = false;
|
||
});
|
||
}
|
||
|
||
@action
|
||
editMessage(chatMessage, newContent, uploads) {
|
||
this.sendingLoading = true;
|
||
let data = {
|
||
new_message: newContent,
|
||
upload_ids: (uploads || []).map((upload) => upload.id),
|
||
};
|
||
return ajax(`/chat/${this.args.channel.id}/edit/${chatMessage.id}`, {
|
||
type: "PUT",
|
||
data,
|
||
})
|
||
.then(() => {
|
||
this._resetAfterSend();
|
||
})
|
||
.catch(popupAjaxError)
|
||
.finally(() => {
|
||
if (this._selfDeleted) {
|
||
return;
|
||
}
|
||
this.sendingLoading = false;
|
||
});
|
||
}
|
||
|
||
_resetAfterSend() {
|
||
if (this._selfDeleted) {
|
||
return;
|
||
}
|
||
|
||
this.replyToMsg = null;
|
||
this.editingMessage = null;
|
||
this.chatComposerPresenceManager.notifyState(this.args.channel.id, false);
|
||
this.appEvents.trigger("chat-composer:reply-to-set", null);
|
||
}
|
||
|
||
@action
|
||
editLastMessageRequested() {
|
||
const lastUserMessage = this.args.channel.messages.findLast(
|
||
(message) =>
|
||
message.user.id === this.currentUser.id &&
|
||
!message.staged &&
|
||
!message.error
|
||
);
|
||
|
||
if (lastUserMessage) {
|
||
this.editingMessage = lastUserMessage;
|
||
this._focusComposer();
|
||
}
|
||
}
|
||
|
||
@action
|
||
setReplyTo(messageId) {
|
||
if (messageId) {
|
||
this.cancelEditing();
|
||
|
||
const message = this.args.channel.findMessage(messageId);
|
||
this.replyToMsg = message;
|
||
this.appEvents.trigger("chat-composer:reply-to-set", message);
|
||
this._focusComposer();
|
||
} else {
|
||
this.replyToMsg = null;
|
||
this.appEvents.trigger("chat-composer:reply-to-set", null);
|
||
}
|
||
}
|
||
|
||
@action
|
||
replyMessageClicked(message) {
|
||
const replyMessageFromLookup = this.args.channel.findMessage(message.id);
|
||
if (replyMessageFromLookup) {
|
||
this.scrollToMessage(replyMessageFromLookup.id, {
|
||
highlight: true,
|
||
position: "start",
|
||
autoExpand: true,
|
||
});
|
||
} else {
|
||
// Message is not present in the loaded messages. Fetch it!
|
||
this.requestedTargetMessageId = message.id;
|
||
this.fetchMessages();
|
||
}
|
||
}
|
||
|
||
@action
|
||
editButtonClicked(messageId) {
|
||
const message = this.args.channel.findMessage(messageId);
|
||
this.editingMessage = message;
|
||
this.scrollToLatestMessage();
|
||
this._focusComposer();
|
||
}
|
||
|
||
get canInteractWithChat() {
|
||
return !this.args.channel?.userSilenced;
|
||
}
|
||
|
||
get chatProgressBarContainer() {
|
||
return document.querySelector("#chat-progress-bar-container");
|
||
}
|
||
|
||
get selectedMessageIds() {
|
||
return this.args.channel?.messages
|
||
?.filter((m) => m.selected)
|
||
?.map((m) => m.id);
|
||
}
|
||
|
||
@action
|
||
onStartSelectingMessages(message) {
|
||
this._lastSelectedMessage = message;
|
||
this.selectingMessages = true;
|
||
}
|
||
|
||
@action
|
||
cancelSelecting() {
|
||
this.selectingMessages = false;
|
||
this.args.channel.messages.forEach((message) => {
|
||
message.selected = false;
|
||
});
|
||
}
|
||
|
||
@action
|
||
onSelectMessage(message) {
|
||
this._lastSelectedMessage = message;
|
||
}
|
||
|
||
@action
|
||
bulkSelectMessages(message, checked) {
|
||
const lastSelectedIndex = this._findIndexOfMessage(
|
||
this._lastSelectedMessage
|
||
);
|
||
const newlySelectedIndex = this._findIndexOfMessage(message);
|
||
const sortedIndices = [lastSelectedIndex, newlySelectedIndex].sort(
|
||
(a, b) => a - b
|
||
);
|
||
|
||
for (let i = sortedIndices[0]; i <= sortedIndices[1]; i++) {
|
||
this.args.channel.messages[i].selected = checked;
|
||
}
|
||
}
|
||
|
||
_findIndexOfMessage(message) {
|
||
return this.args.channel.messages.findIndex((m) => m.id === message.id);
|
||
}
|
||
|
||
@action
|
||
onCloseFullScreen() {
|
||
this.chatStateManager.prefersDrawer();
|
||
this.router.transitionTo(this.chatStateManager.lastKnownAppURL).then(() => {
|
||
this.appEvents.trigger(
|
||
"chat:open-url",
|
||
this.chatStateManager.lastKnownChatURL
|
||
);
|
||
});
|
||
}
|
||
|
||
@action
|
||
cancelEditing() {
|
||
this.editingMessage = null;
|
||
}
|
||
|
||
@action
|
||
setInReplyToMsg(inReplyMsg) {
|
||
this.replyToMsg = inReplyMsg;
|
||
}
|
||
|
||
@action
|
||
composerValueChanged({ value, uploads, replyToMsg, inProgressUploadsCount }) {
|
||
if (!this.editingMessage && !this.args.channel.isDraft) {
|
||
if (typeof value !== "undefined") {
|
||
this.args.channel.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.args.channel.draft.uploads = uploads;
|
||
}
|
||
|
||
if (typeof replyToMsg !== "undefined") {
|
||
this.args.channel.draft.replyToMsg = replyToMsg;
|
||
}
|
||
}
|
||
|
||
if (!this.args.channel.isDraft) {
|
||
this._reportReplyingPresence(value);
|
||
}
|
||
|
||
this._persistDraft();
|
||
}
|
||
|
||
@debounce(2000)
|
||
_persistDraft() {
|
||
if (this._selfDeleted) {
|
||
return;
|
||
}
|
||
|
||
if (!this.args.channel.draft) {
|
||
return;
|
||
}
|
||
|
||
ajax("/chat/drafts.json", {
|
||
type: "POST",
|
||
data: {
|
||
chat_channel_id: this.args.channel.id,
|
||
data: this.args.channel.draft.toJSON(),
|
||
},
|
||
ignoreUnsent: false,
|
||
})
|
||
.then(() => {
|
||
this.chat.markNetworkAsReliable();
|
||
})
|
||
.catch((error) => {
|
||
// we ignore a draft which can't be saved because it's too big
|
||
// and only deal with network error for now
|
||
if (!error.jqXHR?.responseJSON?.errors?.length) {
|
||
this.chat.markNetworkAsUnreliable();
|
||
}
|
||
});
|
||
}
|
||
|
||
@action
|
||
onHoverMessage(message, options = {}, event) {
|
||
if (this.site.mobileView && options.desktopOnly) {
|
||
return;
|
||
}
|
||
|
||
if (this.isScrolling) {
|
||
return;
|
||
}
|
||
|
||
if (message?.staged) {
|
||
return;
|
||
}
|
||
|
||
if (
|
||
this.hoveredMessageId &&
|
||
message?.id &&
|
||
this.hoveredMessageId === message?.id
|
||
) {
|
||
return;
|
||
}
|
||
|
||
if (event) {
|
||
if (
|
||
event.type === "mouseleave" &&
|
||
(event.toElement || event.relatedTarget)?.closest(
|
||
".chat-message-actions-desktop-anchor"
|
||
)
|
||
) {
|
||
return;
|
||
}
|
||
|
||
if (
|
||
event.type === "mouseenter" &&
|
||
(event.fromElement || event.relatedTarget)?.closest(
|
||
".chat-message-actions-desktop-anchor"
|
||
)
|
||
) {
|
||
this.hoveredMessageId = message?.id;
|
||
return;
|
||
}
|
||
}
|
||
|
||
this.hoveredMessageId =
|
||
message?.id && message.id !== this.hoveredMessageId ? message.id : null;
|
||
}
|
||
|
||
_reportReplyingPresence(composerValue) {
|
||
if (this._selfDeleted) {
|
||
return;
|
||
}
|
||
|
||
if (this.args.channel.isDraft) {
|
||
return;
|
||
}
|
||
|
||
const replying = !this.editingMessage && !!composerValue;
|
||
this.chatComposerPresenceManager.notifyState(
|
||
this.args.channel.id,
|
||
replying
|
||
);
|
||
}
|
||
|
||
_focusComposer() {
|
||
this.appEvents.trigger("chat:focus-composer");
|
||
}
|
||
|
||
_unsubscribeToUpdates(channelId) {
|
||
if (!channelId) {
|
||
return;
|
||
}
|
||
|
||
this.messageBus.unsubscribe(`/chat/${channelId}`, this.onMessage);
|
||
}
|
||
|
||
_subscribeToUpdates(channelId) {
|
||
if (!channelId) {
|
||
return;
|
||
}
|
||
|
||
this._unsubscribeToUpdates(channelId);
|
||
this.messageBus.subscribe(
|
||
`/chat/${channelId}`,
|
||
this.onMessage,
|
||
this.args.channel.channelMessageBusLastId
|
||
);
|
||
}
|
||
|
||
@bind
|
||
_forceBodyScroll() {
|
||
// when keyboard is visible this will ensure body
|
||
// doesn’t scroll out of viewport
|
||
if (
|
||
this.capabilities.isIOS &&
|
||
document.documentElement.classList.contains("keyboard-visible") &&
|
||
!isZoomed()
|
||
) {
|
||
document.documentElement.scrollTo(0, 0);
|
||
}
|
||
}
|
||
|
||
_fetchAndScrollToLatest() {
|
||
this.loadedOnce = false;
|
||
return this.fetchMessages({
|
||
fetchFromLastMessage: true,
|
||
});
|
||
}
|
||
|
||
_handleErrors(error) {
|
||
switch (error?.jqXHR?.status) {
|
||
case 429:
|
||
case 404:
|
||
popupAjaxError(error);
|
||
break;
|
||
default:
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// 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._scrollerEl) {
|
||
return;
|
||
}
|
||
|
||
if (this.capabilities.isIOS) {
|
||
this._scrollerEl.style.transform = "translateZ(0)";
|
||
this._scrollerEl.style.overflow = "hidden";
|
||
}
|
||
|
||
callback?.();
|
||
|
||
if (this.capabilities.isIOS) {
|
||
discourseLater(() => {
|
||
if (!this._scrollerEl) {
|
||
return;
|
||
}
|
||
|
||
this._scrollerEl.style.overflow = "auto";
|
||
this._scrollerEl.style.transform = "unset";
|
||
this.computeDatesSeparators();
|
||
}, 50);
|
||
}
|
||
});
|
||
}
|
||
|
||
@action
|
||
addAutoFocusEventListener() {
|
||
document.addEventListener("keydown", this._autoFocus);
|
||
}
|
||
|
||
@action
|
||
removeAutoFocusEventListener() {
|
||
document.removeEventListener("keydown", this._autoFocus);
|
||
}
|
||
|
||
@bind
|
||
_autoFocus(event) {
|
||
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)) {
|
||
return;
|
||
}
|
||
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
|
||
const composer = document.querySelector(".chat-composer-input");
|
||
if (composer && !this.args.channel.isDraft) {
|
||
this.appEvents.trigger("chat:insert-text", key);
|
||
composer.focus();
|
||
}
|
||
}
|
||
|
||
@action
|
||
computeDatesSeparators() {
|
||
schedule("afterRender", () => {
|
||
const dates = [
|
||
...this._scrollerEl.querySelectorAll(".chat-message-separator-date"),
|
||
].reverse();
|
||
const scrollHeight = this._scrollerEl.scrollHeight;
|
||
|
||
dates
|
||
.map((date, index) => {
|
||
const item = { bottom: "0px", date };
|
||
if (index > 0) {
|
||
item.bottom = scrollHeight - dates[index - 1].offsetTop + "px";
|
||
}
|
||
item.top = date.nextElementSibling.offsetTop + "px";
|
||
return item;
|
||
})
|
||
// group all writes at the end
|
||
.forEach((item) => {
|
||
item.date.style.bottom = item.bottom;
|
||
item.date.style.top = item.top;
|
||
});
|
||
});
|
||
}
|
||
|
||
#isAtBottom() {
|
||
return Math.abs(this._scrollerEl.scrollTop) <= 2;
|
||
}
|
||
|
||
#isTowardsBottom() {
|
||
return Math.abs(this._scrollerEl.scrollTop) <= 50;
|
||
}
|
||
|
||
#isAtTop() {
|
||
return (
|
||
Math.abs(this._scrollerEl.scrollTop) >=
|
||
this._scrollerEl.scrollHeight - this._scrollerEl.offsetHeight - 2
|
||
);
|
||
}
|
||
|
||
#isBottomOfMessageVisible(element, container) {
|
||
const rect = element.getBoundingClientRect();
|
||
const containerRect = container.getBoundingClientRect();
|
||
// - 1.0 to account for rounding errors, especially on firefox
|
||
return rect.bottom - 1.0 <= containerRect.bottom;
|
||
}
|
||
}
|