import isElementInViewport from "discourse/lib/is-element-in-viewport"; import { cloneJSON } from "discourse-common/lib/object"; import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; import Component from "@ember/component"; import discourseComputed, { afterRender, bind, debounce, observes, } from "discourse-common/utils/decorators"; import discourseDebounce from "discourse-common/lib/debounce"; import EmberObject, { action } from "@ember/object"; import I18n from "I18n"; import { A } from "@ember/array"; import { ajax } from "discourse/lib/ajax"; import { popupAjaxError } from "discourse/lib/ajax-error"; import { cancel, next, schedule, throttle } 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 { capitalize } from "@ember/string"; import { onPresenceChange, removeOnPresenceChange, } from "discourse/lib/user-presence"; import isZoomed from "discourse/plugins/chat/discourse/lib/zoom-check"; import { isTesting } from "discourse-common/config/environment"; const MAX_RECENT_MSGS = 100; const STICKY_SCROLL_LENIENCE = 50; const PAGE_SIZE = 50; const SCROLL_HANDLER_THROTTLE_MS = isTesting() ? 0 : 100; const FETCH_MORE_MESSAGES_THROTTLE_MS = isTesting() ? 0 : 500; const PAST = "past"; const FUTURE = "future"; export default Component.extend({ classNameBindings: [":chat-live-pane", "sendingLoading", "loading"], chatChannel: null, registeredChatChannelId: null, // ?Number loading: false, loadingMorePast: false, loadingMoreFuture: false, hoveredMessageId: null, onSwitchChannel: null, allPastMessagesLoaded: false, sendingLoading: false, selectingMessages: false, stickyScroll: true, stickyScrollTimer: null, showChatQuoteSuccess: false, showCloseFullScreenBtn: false, includeHeader: true, editingMessage: null, // ?Message replyToMsg: null, // ?Message details: null, // Object { chat_channel_id, ... } messages: null, // Array messageLookup: null, // Object _unloadedReplyIds: null, // Array _nextStagedMessageId: 0, // Iterate on every new message _lastSelectedMessage: null, targetMessageId: null, hasNewMessages: null, chat: service(), chatChannelsManager: service(), router: service(), chatEmojiPickerManager: service(), chatComposerPresenceManager: service(), chatStateManager: service(), chatApi: service(), getCachedChannelDetails: null, clearCachedChannelDetails: null, _scrollerEl: null, init() { this._super(...arguments); this.set("messages", []); this.set("_mentionWarningsSeen", {}); this.set("unreachableGroupMentions", []); this.set("overMembersLimitGroupMentions", []); }, didInsertElement() { this._super(...arguments); this._unloadedReplyIds = []; this.appEvents.on( "chat-live-pane:highlight-message", this, "highlightOrFetchMessage" ); this._scrollerEl = this.element.querySelector(".chat-messages-scroll"); this._scrollerEl.addEventListener("scroll", this.onScrollHandler, { passive: true, }); window.addEventListener("resize", this.onResizeHandler); window.addEventListener("wheel", this.onScrollHandler, { passive: true, }); this.appEvents.on("chat:cancel-message-selection", this, "cancelSelecting"); this.set("showCloseFullScreenBtn", !this.site.mobileView); document.addEventListener("scroll", this._forceBodyScroll, { passive: true, }); onPresenceChange({ callback: this.onPresenceChangeCallback, }); }, willDestroyElement() { this._super(...arguments); this.element .querySelector(".chat-messages-scroll") ?.removeEventListener("scroll", this.onScrollHandler); window.removeEventListener("resize", this.onResizeHandler); window.removeEventListener("wheel", this.onScrollHandler); this.appEvents.off( "chat-live-pane:highlight-message", this, "highlightOrFetchMessage" ); // don't need to removeEventListener from scroller as the DOM element goes away cancel(this.stickyScrollTimer); cancel(this.resizeHandler); this._resetChannelState(); this._unloadedReplyIds = null; this.appEvents.off( "chat:cancel-message-selection", this, "cancelSelecting" ); document.removeEventListener("scroll", this._forceBodyScroll); removeOnPresenceChange(this.onPresenceChangeCallback); }, didReceiveAttrs() { this._super(...arguments); this.currentUserTimezone = this.currentUser?.user_option.timezone; if ( this.chatChannel?.id && this.registeredChatChannelId !== this.chatChannel.id ) { this._resetChannelState(); this.cancelEditing(); if (!this.chatChannel.isDraft) { this.loadDraftForChannel(this.chatChannel.id); } } if (this.chatChannel?.id) { this.fetchMessages(this.chatChannel); } }, @discourseComputed("chatChannel.isDirectMessageChannel") displayMembers(isDirectMessageChannel) { return !isDirectMessageChannel; }, @discourseComputed("displayMembers") infoTabRoute(displayMembers) { if (displayMembers) { return "chat.channel.info.members"; } return "chat.channel.info.settings"; }, @bind onScrollHandler(event) { throttle(this, this.onScroll, event, SCROLL_HANDLER_THROTTLE_MS, true); }, @bind onResizeHandler() { cancel(this.resizeHandler); this.resizeHandler = discourseDebounce( this, this.fillPaneAttempt, this.details, 250 ); }, @bind onPresenceChangeCallback(present) { if (present) { this.chat.updateLastReadMessage(); } }, @debounce(100) fetchMessages(channel, options = {}) { if (this._selfDeleted) { return; } this.set("loading", true); return this.chat.loadCookFunction(this.site.categories).then((cook) => { if (this._selfDeleted) { return; } this.set("cook", cook); const findArgs = { channelId: channel.id, pageSize: PAGE_SIZE, }; const fetchingFromLastRead = !options.fetchFromLastMessage; if (fetchingFromLastRead) { findArgs["targetMessageId"] = this.targetMessageId || this._getLastReadId(); } return this.store .findAll("chat-message", findArgs) .then((messages) => { if (this._selfDeleted || this.chatChannel.id !== channel.id) { return; } this.setMessageProps(messages, fetchingFromLastRead); if (options.fetchFromLastMessage) { this.set("stickyScroll", true); this._stickScrollToBottom(); } this._focusComposer(); }) .catch(this._handleErrors) .finally(() => { if (this._selfDeleted || this.chatChannel.id !== channel.id) { return; } this.set("loading", false); }); }); }, loadDraftForChannel(channelId) { this.set("draft", this.chat.getDraftForChannel(channelId)); }, @bind _fetchMoreMessages(direction) { const loadingPast = direction === PAST; const canLoadMore = loadingPast ? this.details?.can_load_more_past : this.details?.can_load_more_future; const loadingMoreKey = `loadingMore${capitalize(direction)}`; const loadingMore = this.get(loadingMoreKey); if ( (this.details && !canLoadMore) || loadingMore || this.loading || !this.messages.length ) { return Promise.resolve(); } this.set(loadingMoreKey, true); this.ignoreStickyScrolling = true; const messageIndex = loadingPast ? 0 : this.messages.length - 1; const messageId = this.messages[messageIndex].id; const findArgs = { channelId: this.chatChannel.id, pageSize: PAGE_SIZE, direction, messageId, }; const channelId = this.chatChannel.id; return this.store .findAll("chat-message", findArgs) .then((messages) => { if (this._selfDeleted || channelId !== this.chatChannel.id) { return; } const newMessages = this._prepareMessages(messages || []); if (newMessages.length) { this.set( "messages", loadingPast ? newMessages.concat(this.messages) : this.messages.concat(newMessages) ); } this.setCanLoadMoreDetails(messages.resultSetMeta); if (!loadingPast && newMessages.length) { // Adding newer messages also causes a scroll-down, // firing another event, fetching messages again, and so on. // Scroll to the first new one to prevent this. this.scrollToMessage(newMessages.firstObject.messageLookupId); } return messages; }) .catch(this._handleErrors) .finally(() => { if (this._selfDeleted) { return; } this.set(loadingMoreKey, false); this.ignoreStickyScrolling = false; }); }, fillPaneAttempt(meta) { if (this._selfDeleted) { return; } // safeguard if (this.messages.length > 200) { return; } if (!meta?.can_load_more_past) { return; } schedule("afterRender", () => { const firstMessageId = this.messages.firstObject?.id; if (!firstMessageId) { return; } const scroller = document.querySelector(".chat-messages-container"); const messageContainer = document.querySelector( `.chat-message-container[data-id="${firstMessageId}"]` ); if ( !scroller || !messageContainer || !isElementInViewport(messageContainer) ) { return; } this._fetchMoreMessagesThrottled(PAST); }); }, _fetchMoreMessagesThrottled(direction) { throttle( this, "_fetchMoreMessages", direction, FETCH_MORE_MESSAGES_THROTTLE_MS ); }, setCanLoadMoreDetails(meta) { const metaKeys = Object.keys(meta); if (metaKeys.includes("can_load_more_past")) { this.set("details.can_load_more_past", meta.can_load_more_past); this.set( "allPastMessagesLoaded", this.details.can_load_more_past === false ); } if (metaKeys.includes("can_load_more_future")) { this.set("details.can_load_more_future", meta.can_load_more_future); } }, setMessageProps(messages, fetchingFromLastRead) { this._unloadedReplyIds = []; this.messageLookup = {}; const meta = messages.resultSetMeta; this.setProperties({ messages: this._prepareMessages(messages), details: { chat_channel_id: this.chatChannel.id, chatable_type: this.chatChannel.chatable_type, can_delete_self: meta.can_delete_self, can_delete_others: meta.can_delete_others, can_flag: meta.can_flag, user_silenced: meta.user_silenced, can_moderate: meta.can_moderate, channel_message_bus_last_id: meta.channel_message_bus_last_id, }, registeredChatChannelId: this.chatChannel.id, }); schedule("afterRender", () => { if (this._selfDeleted) { return; } if (this.targetMessageId) { this.scrollToMessage(this.targetMessageId, { highlight: true, position: "top", autoExpand: true, }); this.set("targetMessageId", null); } else if (fetchingFromLastRead) { this._markLastReadMessage(); } this.fillPaneAttempt(messages.resultSetMeta); }); this.setCanLoadMoreDetails(messages.resultSetMeta); this._subscribeToUpdates(this.chatChannel.id); }, _prepareMessages(messages) { const preparedMessages = A(); let previousMessage; messages.forEach((currentMessage) => { let prepared = this._prepareSingleMessage( currentMessage, previousMessage ); preparedMessages.push(prepared); previousMessage = prepared; }); return preparedMessages; }, _areDatesOnSameDay(a, b) { return ( a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate() ); }, _prepareSingleMessage(messageData, previousMessageData) { if (previousMessageData) { if ( !this._areDatesOnSameDay( new Date(previousMessageData.created_at), new Date(messageData.created_at) ) ) { messageData.firstMessageOfTheDayAt = moment( messageData.created_at ).calendar(moment(), { sameDay: `[${I18n.t("chat.chat_message_separator.today")}]`, lastDay: `[${I18n.t("chat.chat_message_separator.yesterday")}]`, lastWeek: "LL", sameElse: "LL", }); } } if (messageData.in_reply_to?.id === previousMessageData?.id) { // Reply-to message is directly above. Remove `in_reply_to` from message. messageData.in_reply_to = null; } if (messageData.in_reply_to) { let inReplyToMessage = this.messageLookup[messageData.in_reply_to.id]; if (inReplyToMessage) { // Reply to message has already been added messageData.in_reply_to = inReplyToMessage; } else { inReplyToMessage = EmberObject.create(messageData.in_reply_to); this._unloadedReplyIds.push(inReplyToMessage.id); this.messageLookup[inReplyToMessage.id] = inReplyToMessage; } } else { // In reply-to is false. Check if previous message was created by same // user and if so, no need to repeat avatar and username if ( previousMessageData && !previousMessageData.deleted_at && Math.abs( new Date(messageData.created_at) - new Date(previousMessageData.created_at) ) < 300000 && // If the time between messages is over 5 minutes, break. messageData.user.id === previousMessageData.user.id ) { messageData.hideUserInfo = true; } } this._handleMessageHidingAndExpansion(messageData); messageData.messageLookupId = this._generateMessageLookupId(messageData); const prepared = ChatMessage.create(messageData); this.messageLookup[messageData.messageLookupId] = prepared; return prepared; }, _handleMessageHidingAndExpansion(messageData) { if (this.currentUser.ignored_users) { messageData.hidden = this.currentUser.ignored_users.includes( messageData.user.username ); } // If a message has been hidden it is because the current user is ignoring // the user who sent it, so we want to unconditionally hide it, even if // we are going directly to the target if (this.targetMessageId && this.targetMessageId === messageData.id) { messageData.expanded = !messageData.hidden; } else { messageData.expanded = !(messageData.hidden || messageData.deleted_at); } }, _generateMessageLookupId(message) { return message.id || `staged-${message.stagedId}`; }, _getLastReadId() { return this.chatChannel.currentUserMembership.last_read_message_id; }, _markLastReadMessage(opts = { reRender: false }) { if (opts.reRender) { this.messages.forEach((m) => { if (m.newestMessage) { m.set("newestMessage", false); } }); } const lastReadId = this._getLastReadId(); if (!lastReadId) { return; } const indexOfLastReadMessage = this.messages.findIndex((m) => m.id === lastReadId) || 0; let newestUnreadMessage = this.messages[indexOfLastReadMessage + 1]; if (newestUnreadMessage && !this.targetMessageId) { newestUnreadMessage.set("newestMessage", true); next(() => this.scrollToMessage(newestUnreadMessage.id)); return; } this._stickScrollToBottom(); }, highlightOrFetchMessage(messageId) { if (this._selfDeleted) { return; } this.set("targetMessageId", messageId); if (this.messageLookup[messageId]) { // We have the message rendered. highlight and scrollTo this.scrollToMessage(messageId, { highlight: true, position: "top", autoExpand: true, }); } else { this.fetchMessages(this.chatChannel); } }, scrollToMessage( messageId, opts = { highlight: false, position: "top", autoExpand: false } ) { if (this._selfDeleted) { return; } const message = this.messageLookup[messageId]; if (message?.deleted_at && opts.autoExpand) { message.set("expanded", true); } schedule("afterRender", () => { const messageEl = this._scrollerEl.querySelector( `.chat-message-container[data-id='${messageId}']` ); if (!messageEl || this._selfDeleted) { return; } this._wrapIOSFix(() => { messageEl.scrollIntoView({ block: opts.position === "top" ? "start" : "end", }); }); if (opts.highlight) { messageEl.classList.add("highlighted"); // Remove highlighted class, but keep `transition-slow` on for another 2 seconds // to ensure the background color fades smoothly out if (opts.highlight) { discourseLater(() => { messageEl.classList.add("transition-slow"); }, 2000); discourseLater(() => { messageEl.classList.remove("highlighted"); discourseLater(() => { messageEl.classList.remove("transition-slow"); }, 2000); }, 3000); } } }); }, @afterRender _stickScrollToBottom() { if (this.ignoreStickyScrolling) { return; } this.set("stickyScroll", true); if (this._scrollerEl) { // Trigger a tiny scrollTop change so Safari scrollbar is placed at bottom. // Setting to just 0 doesn't work (it's at 0 by default, so there is no change) // Very hacky, but no way to get around this Safari bug this._scrollerEl.scrollTop = -1; this._wrapIOSFix(() => { this._scrollerEl.scrollTop = 0; this.set("showScrollToBottomBtn", false); }); } }, onScroll(event) { if (this._selfDeleted) { return; } resetIdle(); const atTop = Math.abs( this._scrollerEl.scrollHeight - this._scrollerEl.clientHeight + this._scrollerEl.scrollTop ) <= STICKY_SCROLL_LENIENCE; if (atTop) { this._fetchMoreMessagesThrottled(PAST); } else if (Math.abs(this._scrollerEl.scrollTop) <= STICKY_SCROLL_LENIENCE) { this._fetchMoreMessagesThrottled(FUTURE); } this._calculateStickScroll(event.forceShowScrollToBottom); }, _calculateStickScroll(forceShowScrollToBottom) { const absoluteScrollTop = Math.abs(this._scrollerEl.scrollTop); const shouldStick = absoluteScrollTop < STICKY_SCROLL_LENIENCE; if (forceShowScrollToBottom) { this.set("showScrollToBottomBtn", forceShowScrollToBottom); } else { this.set( "showScrollToBottomBtn", shouldStick ? false : absoluteScrollTop / this._scrollerEl.offsetHeight > 0.67 ); } if (!this.showScrollToBottomBtn) { this.set("hasNewMessages", false); } if (shouldStick !== this.stickyScroll) { if (shouldStick) { this._stickScrollToBottom(); } else { this.set("stickyScroll", false); } } }, @observes("chatStateManager.isDrawerActive") onFloatHiddenChange() { if (this.chatStateManager.isDrawerActive) { this.set("expanded", true); this._markLastReadMessage({ reRender: true }); this._stickScrollToBottom(); } }, removeMessage(msgData) { delete this.messageLookup[msgData.id]; }, handleMessage(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; } }, handleSentMessage(data) { if (this.chatChannel.isFollowing) { this.chatChannel.set("last_message_sent_at", new Date()); } if (data.chat_message.user.id === this.currentUser.id) { // User sent this message. Check staged messages to see if this client sent the message. // If so, need to update the staged message with and id. const stagedMessage = this.messageLookup[`staged-${data.stagedId}`]; if (stagedMessage) { stagedMessage.setProperties({ error: null, staged: false, id: data.chat_message.id, staged_id: null, excerpt: data.chat_message.excerpt, thread_id: data.chat_message.thread_id, }); const inReplyToMsg = this.messageLookup[data.chat_message.in_reply_to?.id]; if (inReplyToMsg && !inReplyToMsg.thread_id) { inReplyToMsg.set("thread_id", 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 && data.chat_message.cooked !== stagedMessage.cooked ) { stagedMessage.set("cooked", data.chat_message.cooked); } this.appEvents.trigger( `chat-message-staged-${data.stagedId}:id-populated` ); this.messageLookup[data.chat_message.id] = stagedMessage; delete this.messageLookup[`staged-${data.stagedId}`]; return; } } const preparedMessage = this._prepareSingleMessage( data.chat_message, this.messages[this.messages.length - 1] ); this.messages.pushObject(preparedMessage); if (this.messages.length >= MAX_RECENT_MSGS) { this.removeMessage(this.messages.shiftObject()); } this.reStickScrollIfNeeded(); }, handleProcessedMessage(data) { const message = this.messageLookup[data.chat_message.id]; if (message) { message.set("cooked", data.chat_message.cooked); this.reStickScrollIfNeeded(); } }, handleRefreshMessage(data) { const message = this.messageLookup[data.chat_message.id]; if (message) { this.appEvents.trigger("chat:refresh-message", message); } }, handleEditMessage(data) { const message = this.messageLookup[data.chat_message.id]; if (message) { message.setProperties({ message: data.chat_message.message, cooked: data.chat_message.cooked, excerpt: data.chat_message.excerpt, uploads: cloneJSON(data.chat_message.uploads || []), 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.messageLookup[deletedId]; if (this.currentUser.staff || this.currentUser.id === targetMsg.user.id) { targetMsg.setProperties({ deleted_at: data.deleted_at, expanded: false, }); } else { this.messages.removeObject(targetMsg); this.messageLookup[deletedId] = null; } }, handleReactionMessage(data) { this.appEvents.trigger( `chat-message-${data.chat_message_id}:reaction`, data ); }, handleRestoreMessage(data) { let message = this.messageLookup[data.chat_message.id]; if (message) { message.set("deleted_at", null); } else { // The message isn't present in the list for this user. Find the index // where we should push the message to. Binary search is O(log(n)) let newMessageIndex = this.binarySearchForMessagePosition( this.messages, message ); const previousMessage = newMessageIndex > 0 ? this.messages[newMessageIndex - 1] : null; message = this._prepareSingleMessage(data.chat_message, previousMessage); if (newMessageIndex === 0) { return; } // Restored post is too old to show this.messages.splice(newMessageIndex, 0, message); this.notifyPropertyChange("messages"); } }, binarySearchForMessagePosition(messages, newMessage) { const newMessageCreatedAt = Date.parse(newMessage.created_at); if (newMessageCreatedAt < Date.parse(messages[0].created_at)) { return 0; } if ( newMessageCreatedAt > Date.parse(messages[messages.length - 1].created_at) ) { return messages.length; } let m = 0; let n = messages.length - 1; while (m <= n) { let k = Math.floor((n + m) / 2); let comparison = this.compareCreatedAt(newMessageCreatedAt, messages[k]); if (comparison > 0) { m = k + 1; } else if (comparison < 0) { n = k - 1; } else { return k; } } return m; }, compareCreatedAt(newMessageCreatedAt, comparatorMessage) { const compareDate = Date.parse(comparatorMessage.created_at); if (newMessageCreatedAt > compareDate) { return 1; } else if (newMessageCreatedAt < compareDate) { return -1; } return 0; }, handleMentionWarning(data) { this.messageLookup[data.chat_message_id]?.set("mentionWarning", data); }, handleSelfFlaggedMessage(data) { this.messageLookup[data.chat_message_id]?.set( "user_flag_status", data.user_flag_status ); }, handleFlaggedMessage(data) { this.messageLookup[data.chat_message_id]?.set( "reviewable_id", data.reviewable_id ); }, get _selfDeleted() { return !this.element || this.isDestroying || this.isDestroyed; }, @action sendMessage(message, uploads = []) { resetIdle(); if (this.sendingLoading) { return; } this.set("sendingLoading", true); this._setDraftForChannel(null); // 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.chatChannel.isFollowing || this.chatChannel.isDraft) { this.set("loading", true); return this._upsertChannelWithMessage( this.chatChannel, message, uploads ).finally(() => { if (this._selfDeleted) { return; } this.set("loading", false); this.set("sendingLoading", false); this._resetAfterSend(); this._stickScrollToBottom(); }); } this.set("_nextStagedMessageId", this._nextStagedMessageId + 1); const cooked = this.cook(message); const stagedId = this._nextStagedMessageId; let data = { message, cooked, staged_id: stagedId, upload_ids: uploads.map((upload) => upload.id), }; if (this.replyToMsg) { data.in_reply_to_id = this.replyToMsg.id; } // Start ajax request but don't return here, we want to stage the message instantly when all messages are loaded. // Otherwise, we'll fetch latest and scroll to the one we just created. // Return a resolved promise below. const msgCreationPromise = this.chatApi .sendMessage(this.chatChannel.id, data) .catch((error) => { this._onSendError(data.staged_id, error); }) .finally(() => { if (this._selfDeleted) { return; } this.set("sendingLoading", false); }); if (this.details?.can_load_more_future) { msgCreationPromise.then(() => this._fetchAndScrollToLatest()); } else { const stagedMessage = this._prepareSingleMessage( // We need to add the user and created at for presentation of staged message { message, cooked, stagedId, uploads: cloneJSON(uploads), staged: true, user: this.currentUser, in_reply_to: this.replyToMsg, created_at: new Date(), }, this.messages[this.messages.length - 1] ); this.messages.pushObject(stagedMessage); this._stickScrollToBottom(); } this._resetAfterSend(); this.appEvents.trigger("chat-composer:reply-to-set", null); return Promise.resolve(); }, 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.onSwitchChannel(c); }) ); }, _onSendError(stagedId, error) { const stagedMessage = this.messageLookup[`staged-${stagedId}`]; if (stagedMessage) { if (error.jqXHR?.responseJSON?.errors?.length) { stagedMessage.set("error", error.jqXHR.responseJSON.errors[0]); } else { this.chat.markNetworkAsUnreliable(); stagedMessage.set("error", "network_error"); } } this._resetAfterSend(); }, @action resendStagedMessage(stagedMessage) { this.set("sendingLoading", true); stagedMessage.set("error", null); const data = { cooked: stagedMessage.cooked, message: stagedMessage.message, upload_ids: stagedMessage.upload_ids, staged_id: stagedMessage.stagedId, }; this.chatApi .sendMessage(this.chatChannel.id, data) .catch((error) => { this._onSendError(data.staged_id, error); }) .then(() => { this.chat.markNetworkAsReliable(); }) .finally(() => { if (this._selfDeleted) { return; } this.set("sendingLoading", false); }); }, @action editMessage(chatMessage, newContent, uploads) { this.set("sendingLoading", true); let data = { new_message: newContent, upload_ids: (uploads || []).map((upload) => upload.id), }; return ajax(`/chat/${this.chatChannel.id}/edit/${chatMessage.id}`, { type: "PUT", data, }) .then(() => { this._resetAfterSend(); }) .catch(popupAjaxError) .finally(() => { if (this._selfDeleted) { return; } this.set("sendingLoading", false); }); }, _resetChannelState() { this._unsubscribeToUpdates(this.registeredChatChannelId); this.messages.clear(); this.messageLookup = {}; this.set("allPastMessagesLoaded", false); this.set("registeredChatChannelId", null); this.set("selectingMessages", false); }, _resetAfterSend() { if (this._selfDeleted) { return; } this.setProperties({ replyToMsg: null, editingMessage: null, }); this.chatComposerPresenceManager.notifyState(this.chatChannel.id, false); }, @action editLastMessageRequested() { let lastUserMessage = null; for ( let messageIndex = this.messages.length - 1; messageIndex >= 0; messageIndex-- ) { let message = this.messages[messageIndex]; if ( !message.staged && message.user.id === this.currentUser.id && !message.error ) { lastUserMessage = message; break; } } if (lastUserMessage) { this.set("editingMessage", lastUserMessage); this._focusComposer(); } }, @action setReplyTo(messageId) { if (messageId) { this.cancelEditing(); this.set("replyToMsg", this.messageLookup[messageId]); this.appEvents.trigger("chat-composer:reply-to-set", this.replyToMsg); this._focusComposer(); } else { this.set("replyToMsg", null); this.appEvents.trigger("chat-composer:reply-to-set", null); } }, @action replyMessageClicked(message) { const replyMessageFromLookup = this.messageLookup[message.id]; if (this._unloadedReplyIds.includes(message.id)) { // Message is not present in the loaded messages. Fetch it! this.set("targetMessageId", message.id); this.fetchMessages(this.chatChannel); } else { this.scrollToMessage(replyMessageFromLookup.id, { highlight: true, position: "top", autoExpand: true, }); } }, @action editButtonClicked(messageId) { const message = this.messageLookup[messageId]; this.set("editingMessage", message); next(this.reStickScrollIfNeeded.bind(this)); this._focusComposer(); }, @discourseComputed("details.user_silenced") canInteractWithChat(userSilenced) { return !userSilenced; }, @discourseComputed chatProgressBarContainer() { return document.querySelector("#chat-progress-bar-container"); }, @discourseComputed("messages.@each.selected") selectedMessageIds(messages) { return messages.filter((m) => m.selected).map((m) => m.id); }, @action onStartSelectingMessages(message) { this._lastSelectedMessage = message; this.set("selectingMessages", true); }, @action cancelSelecting() { this.set("selectingMessages", false); this.messages.setEach("selected", false); }, @action onSelectMessage(message) { this._lastSelectedMessage = message; }, @action navigateToIndex() { this.router.transitionTo("chat.index"); }, @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.messages[i].set("selected", checked); } }, _findIndexOfMessage(message) { return this.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.set("editingMessage", null); }, @action _setDraftForChannel(draft) { if (this.chatChannel.isDraft) { return; } if (draft?.replyToMsg) { draft.replyToMsg = { id: draft.replyToMsg.id, excerpt: draft.replyToMsg.excerpt, user: draft.replyToMsg.user, }; } this.chat.setDraftForChannel(this.chatChannel, draft); this.set("draft", draft); }, @action setInReplyToMsg(inReplyMsg) { this.set("replyToMsg", inReplyMsg); }, @action composerValueChanged(value, uploads, replyToMsg) { if (!this.editingMessage && !this.chatChannel.directMessageChannelDraft) { this._setDraftForChannel({ value, uploads, replyToMsg }); } if (!this.chatChannel.directMessageChannelDraft) { this._reportReplyingPresence(value); } }, @action reStickScrollIfNeeded() { if (this.stickyScroll) { this._stickScrollToBottom(); } }, @action onHoverMessage(message, options = {}, event) { if (this.site.mobileView && options.desktopOnly) { 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.set("hoveredMessageId", message?.id); return; } } this._onHoverMessageDebouncedHandler = discourseDebounce( this, this.debouncedOnHoverMessage, message, 250 ); }, @bind debouncedOnHoverMessage(message) { if (this._selfDeleted) { return; } this.set( "hoveredMessageId", message?.id && message.id !== this.hoveredMessageId ? message.id : null ); }, _reportReplyingPresence(composerValue) { if (this._selfDeleted) { return; } if (this.chatChannel.isDraft) { return; } const replying = !this.editingMessage && !!composerValue; this.chatComposerPresenceManager.notifyState(this.chatChannel.id, replying); }, @action restickScrolling(event) { event.preventDefault(); return this._fetchAndScrollToLatest(); }, _focusComposer() { this.appEvents.trigger("chat:focus-composer"); }, _unsubscribeToUpdates(channelId) { this.messageBus.unsubscribe(`/chat/${channelId}`, this.onMessage); }, _subscribeToUpdates(channelId) { this._unsubscribeToUpdates(channelId); this.messageBus.subscribe( `/chat/${channelId}`, this.onMessage, this.details.channel_message_bus_last_id ); }, @bind onMessage(busData) { if (!this.details.can_load_more_future || busData.type !== "sent") { this.handleMessage(busData); } else { this.set("hasNewMessages", true); } }, @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() { return this.fetchMessages(this.chatChannel, { 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 _wrapIOSFix(callback) { if (!this._scrollerEl) { return; } if (this.capabilities.isIOS) { this._scrollerEl.style.overflow = "hidden"; } callback(); if (this.capabilities.isIOS) { discourseLater(() => { if (!this._scrollerEl) { return; } this._scrollerEl.style.overflow = "auto"; }, 25); } }, });