diff --git a/plugins/chat/app/controllers/chat_controller.rb b/plugins/chat/app/controllers/chat_controller.rb index ffcad435e5..df9da9b669 100644 --- a/plugins/chat/app/controllers/chat_controller.rb +++ b/plugins/chat/app/controllers/chat_controller.rb @@ -209,6 +209,7 @@ class Chat::ChatController < Chat::ChatBaseController messages = preloaded_chat_message_query.where(chat_channel: @chat_channel) messages = messages.with_deleted if guardian.can_moderate_chat?(@chatable) + messages = messages.where(thread_id: params[:thread_id]) if params[:thread_id] if message_id.present? condition = direction == PAST ? "<" : ">" @@ -299,6 +300,7 @@ class Chat::ChatController < Chat::ChatBaseController messages = preloaded_chat_message_query.where(chat_channel: @chat_channel) messages = messages.with_deleted if guardian.can_moderate_chat?(@chatable) + messages = messages.where(thread_id: params[:thread_id]) if params[:thread_id] past_messages = messages diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-thread.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-thread.hbs index 3238d28a61..43990a7e5e 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-thread.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-thread.hbs @@ -1,4 +1,8 @@ -
+
@@ -14,7 +18,7 @@

- {{replace-emoji this.thread.original_message.excerpt}} + {{replace-emoji this.thread.originalMessage.excerpt}}

@@ -23,14 +27,36 @@ }} {{this.thread.original_message_user.username}} + >{{this.thread.originalMessageUser.username}}
+
    + {{#each this.thread.messages as |message|}} +
  • {{message.user.username}} sez: {{message.message}}
  • + {{/each}} +
+ {{#if (or this.loading this.loadingMoreFuture)}} + + {{/if}}
+ +
\ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-thread.js b/plugins/chat/assets/javascripts/discourse/components/chat-thread.js index 1c07f0c538..e268b12ab2 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-thread.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-thread.js @@ -1,15 +1,34 @@ import Component from "@glimmer/component"; +import { cloneJSON } from "discourse-common/lib/object"; +import ChatMessageDraft from "discourse/plugins/chat/discourse/models/chat-message-draft"; +import { tracked } from "@glimmer/tracking"; +import { action } from "@ember/object"; +import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { bind, debounce } from "discourse-common/utils/decorators"; import I18n from "I18n"; import { inject as service } from "@ember/service"; +const PAGE_SIZE = 50; + export default class ChatThreadPanel extends Component { @service siteSettings; @service currentUser; @service chat; @service router; + @service chatApi; + @service chatComposerPresenceManager; + @service appEvents; + + @tracked loading; + @tracked loadingMorePast; get thread() { - return this.chat.activeChannel.activeThread; + return this.channel.activeThread; + } + + get channel() { + return this.chat.activeChannel; } get title() { @@ -19,4 +38,238 @@ export default class ChatThreadPanel extends Component { return I18n.t("chat.threads.op_said"); } + + @action + loadMessages() { + if (this.args.targetMessageId) { + this.requestedTargetMessageId = parseInt(this.args.targetMessageId, 10); + } + + // TODO (martin) Loading/scrolling to selected messagew + // this.highlightOrFetchMessage(this.requestedTargetMessageId); + // if (this.requestedTargetMessageId) { + // } else { + this.fetchMessages(); + // } + } + + get _selfDeleted() { + return this.isDestroying || this.isDestroyed; + } + + @debounce(100) + fetchMessages(options = {}) { + if (this._selfDeleted) { + return; + } + + this.loadingMorePast = true; + this.loading = true; + this.thread.clearMessages(); + + const findArgs = { pageSize: PAGE_SIZE }; + + // TODO (martin) Find arguments for last read etc. + // const fetchingFromLastRead = !options.fetchFromLastMessage; + // if (this.requestedTargetMessageId) { + // findArgs["targetMessageId"] = this.requestedTargetMessageId; + // } else if (fetchingFromLastRead) { + // findArgs["targetMessageId"] = this._getLastReadId(); + // } + // + findArgs.threadId = this.thread.id; + + return this.chatApi + .messages(this.channel.id, findArgs) + .then((results) => { + if (this._selfDeleted || this.channel.id !== results.meta.channel_id) { + this.router.transitionTo( + "chat.channel", + "-", + results.meta.channel_id + ); + } + + const [messages, meta] = this.afterFetchCallback(this.channel, results); + this.thread.appendMessages(messages); + + // TODO (martin) ECHO MODE + this.channel.appendMessages(messages); + + // TODO (martin) details needed for thread?? + this.thread.details = meta; + + // TODO (martin) Scrolling to particular messages + // if (this.requestedTargetMessageId) { + // this.scrollToMessage(findArgs["targetMessageId"], { + // highlight: true, + // }); + // } else if (fetchingFromLastRead) { + // this.scrollToMessage(findArgs["targetMessageId"]); + // } else if (messages.length) { + // this.scrollToMessage(messages.lastObject.id); + // } + }) + .catch(this.#handleErrors) + .finally(() => { + if (this._selfDeleted) { + return; + } + + this.requestedTargetMessageId = null; + this.loading = false; + this.loadingMorePast = false; + + // this.fillPaneAttempt(); + }); + } + + @bind + afterFetchCallback(channel, results) { + const messages = []; + let foundFirstNew = false; + + results.chat_messages.forEach((messageData) => { + // 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.currentUser.ignored_users) { + 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 > channel.currentUserMembership.last_read_message_id + ) { + foundFirstNew = true; + messageData.newest = true; + } + + messages.push(ChatMessage.create(channel, messageData)); + }); + + return [messages, results.meta]; + } + + @action + sendMessage(message, uploads = []) { + // TODO (martin) For desktop notifications + // resetIdle() + if (this.sendingLoading) { + return; + } + + this.sendingLoading = true; + this.channel.draft = ChatMessageDraft.create(); + + // TODO (martin) Handling case when channel is not followed???? IDK if we + // even let people send messages in threads without this, seems weird. + + debugger; + const stagedMessage = ChatMessage.createStagedMessage(this.channel, { + message, + created_at: new Date(), + uploads: cloneJSON(uploads), + user: this.currentUser, + thread_id: this.thread.id, + }); + + this.thread.appendMessages([stagedMessage]); + + // TODO (martin) Scrolling!! + // if (!this.channel.canLoadMoreFuture) { + // this.scrollToBottom(); + // } + + return this.chatApi + .sendMessage(this.channel.id, { + message: stagedMessage.message, + in_reply_to_id: stagedMessage.inReplyTo?.id, + staged_id: stagedMessage.stagedId, + upload_ids: stagedMessage.uploads.map((upload) => upload.id), + thread_id: stagedMessage.threadId, + }) + .then(() => { + // TODO (martin) Scrolling!! + // this.scrollToBottom(); + }) + .catch((error) => { + this.#onSendError(stagedMessage.stagedId, error); + }) + .finally(() => { + if (this._selfDeleted) { + return; + } + this.sendingLoading = false; + this.#resetAfterSend(); + }); + } + + @action + editMessage(chatMessage, newContent, uploads) {} + + @action + setReplyTo(messageId) {} + + @action + setInReplyToMsg(inReplyMsg) { + this.replyToMsg = inReplyMsg; + } + + @action + cancelEditing() { + this.editingMessage = null; + } + + @action + editLastMessageRequested() {} + + @action + composerValueChanged(value, uploads, replyToMsg) {} + + #handleErrors(error) { + switch (error?.jqXHR?.status) { + case 429: + case 404: + popupAjaxError(error); + break; + default: + throw error; + } + } + + #onSendError(stagedId, error) { + const stagedMessage = this.thread.findStagedMessage(stagedId); + if (stagedMessage) { + if (error.jqXHR?.responseJSON?.errors?.length) { + stagedMessage.error = error.jqXHR.responseJSON.errors[0]; + } else { + this.chat.markNetworkAsUnreliable(); + stagedMessage.error = "network_error"; + } + } + + this.#resetAfterSend(); + } + + #resetAfterSend() { + if (this._selfDeleted) { + return; + } + + this.replyToMsg = null; + this.editingMessage = null; + this.chatComposerPresenceManager.notifyState(this.channel.id, false); + this.appEvents.trigger("chat-composer:reply-to-set", null); + } } diff --git a/plugins/chat/assets/javascripts/discourse/lib/chat-threads-manager.js b/plugins/chat/assets/javascripts/discourse/lib/chat-threads-manager.js index a19aa7492e..fb278ae783 100644 --- a/plugins/chat/assets/javascripts/discourse/lib/chat-threads-manager.js +++ b/plugins/chat/assets/javascripts/discourse/lib/chat-threads-manager.js @@ -43,7 +43,7 @@ export default class ChatThreadsManager { let model = this.#findStale(threadObject.id); if (!model) { - model = ChatThread.create(threadObject); + model = new ChatThread(threadObject); this.#cache(model); } @@ -55,7 +55,7 @@ export default class ChatThreadsManager { .thread(channelId, threadId) .catch(popupAjaxError) .then((thread) => { - this.#cache(thread); + console.log(thread); return thread; }); } diff --git a/plugins/chat/assets/javascripts/discourse/models/chat-thread.js b/plugins/chat/assets/javascripts/discourse/models/chat-thread.js index abe42551d0..c08178a6df 100644 --- a/plugins/chat/assets/javascripts/discourse/models/chat-thread.js +++ b/plugins/chat/assets/javascripts/discourse/models/chat-thread.js @@ -1,4 +1,5 @@ -import RestModel from "discourse/models/rest"; +import { TrackedArray } from "@ember-compat/tracked-built-ins"; +import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; import User from "discourse/models/user"; import { escapeExpression } from "discourse/lib/utilities"; import { tracked } from "@glimmer/tracking"; @@ -10,22 +11,64 @@ export const THREAD_STATUSES = { archived: "archived", }; -export default class ChatThread extends RestModel { +export default class ChatThread { @tracked title; @tracked status; + @tracked messages = new TrackedArray(); + + constructor(args = {}) { + this.title = args.title; + this.id = args.id; + this.status = args.status; + + this.originalMessageUser = this.#initUserModel(args.original_message_user); + // TODO (martin) Not sure if ChatMessage is needed here, original_message + // only has a small subset of message stuff. + // this.originalMessage = new ChatMessage(args.original_message); + this.originalMessage = args.original_message; + this.originalMessage.user = this.originalMessageUser; + } get escapedTitle() { return escapeExpression(this.title); } -} -ChatThread.reopenClass({ - create(args) { - args = args || {}; - if (!args.original_message_user instanceof User) { - args.original_message_user = User.create(args.original_message_user); + clearMessages() { + this.messages.clear(); + + this.canLoadMoreFuture = null; + this.canLoadMorePast = null; + } + + appendMessages(messages) { + this.messages.pushObjects(messages); + } + + prependMessages(messages) { + this.messages.unshiftObjects(messages); + } + + findMessage(messageId) { + return this.messages.find( + (message) => message.id === parseInt(messageId, 10) + ); + } + + removeMessage(message) { + return this.messages.removeObject(message); + } + + findStagedMessage(stagedMessageId) { + return this.messages.find( + (message) => message.stagedId === stagedMessageId + ); + } + + #initUserModel(user) { + if (!user || user instanceof User) { + return user; } - args.original_message.user = args.original_message_user; - return this._super(args); - }, -}); + + return User.create(user); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/routes/chat-channel.js b/plugins/chat/assets/javascripts/discourse/routes/chat-channel.js index 34ca9343de..3d99c4164b 100644 --- a/plugins/chat/assets/javascripts/discourse/routes/chat-channel.js +++ b/plugins/chat/assets/javascripts/discourse/routes/chat-channel.js @@ -12,8 +12,11 @@ export default class ChatChannelRoute extends DiscourseRoute { willTransition(transition) { // Technically we could keep messages to avoid re-fetching them, but // it's not worth the complexity for now - this.chat.activeChannel?.clearMessages(); + if (!transition?.to?.name?.startsWith("chat.channel.thread")) { + // this.chat.activeChannel?.clearMessages(); + } + this.chat.activeChannel.activeThread?.clearMessages(); this.chat.activeChannel.activeThread = null; this.chatStateManager.closeSidePanel(); diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-api.js b/plugins/chat/assets/javascripts/discourse/services/chat-api.js index 4b51143681..2fe788e711 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat-api.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat-api.js @@ -137,6 +137,7 @@ export default class ChatApi extends Service { * @returns {Promise} */ sendMessage(channelId, data = {}) { + debugger; return ajax(`/chat/${channelId}`, { ignoreUnsent: false, type: "POST", @@ -261,6 +262,10 @@ export default class ChatApi extends Service { if (data.direction) { args.direction = data.direction; } + + if (data.threadId) { + args.thread_id = data.threadId; + } } return ajax(path, { data: args });