From c6ac8978027bd2c8d2de09a4d1a5830ec6705bd6 Mon Sep 17 00:00:00 2001 From: Henry Jameson Date: Fri, 24 Jul 2026 16:22:56 +0300 Subject: [PATCH 1/5] remove chat mode from thread display settings, instead use dedicated page for chat convos --- src/boot/routes.js | 9 + src/components/chat_message/chat_message.js | 13 +- src/components/chat_message/chat_message.vue | 5 +- src/components/chat_view/chat_view.js | 346 +++++++++++------- src/components/chat_view/chat_view.scss | 7 + src/components/chat_view/chat_view.vue | 66 +++- src/components/conversation/conversation.js | 13 +- src/components/conversation/conversation.vue | 48 +-- .../post_status_form/post_status_form.js | 6 + .../post_status_form/post_status_form.vue | 5 +- .../quick_view_settings.vue | 18 - .../scope_selector/scope_selector.js | 14 +- src/i18n/en.json | 2 - 13 files changed, 303 insertions(+), 249 deletions(-) diff --git a/src/boot/routes.js b/src/boot/routes.js index 0dfb1e744..3736036cd 100644 --- a/src/boot/routes.js +++ b/src/boot/routes.js @@ -65,6 +65,15 @@ export default (store) => { component: ConversationPage, meta: { dontScroll: true }, }, + { + name: 'conversation2', + path: '/conversation/:statusId', + component: defineAsyncComponent( + () => import('src/components/chat_view/chat_view.vue'), + ), + props: true, + beforeEnter: validateAuthenticatedRoute, + }, { name: 'quotes', path: '/notice/:id/quotes', component: QuotesTimeline }, { name: 'remote-user-profile-acct', diff --git a/src/components/chat_message/chat_message.js b/src/components/chat_message/chat_message.js index acc1ff31d..ac950a979 100644 --- a/src/components/chat_message/chat_message.js +++ b/src/components/chat_message/chat_message.js @@ -75,14 +75,19 @@ const ChatMessage = { // ChatMessage only has account_id while Status has full user data return !!this.message.user }, - author() { - const accountId = this.isStatus + authorId() { + return this.isStatus ? this.message.user.id : this.message.account_id - - return this.$store.getters.findUser(accountId) + }, + author() { + return this.$store.getters.findUser(this.authorId) }, isCurrentUser() { + // mini-hack/optimizaiton: + // - current user would always be in memory so if user is missing it's obviously not us + // - if anon views page then "us" pretty much doesn't exist + if (!this.author || !this.currentUser) return false return this.author.id === this.currentUser.id }, diff --git a/src/components/chat_message/chat_message.vue b/src/components/chat_message/chat_message.vue index a5c0627a2..f9095a3de 100644 --- a/src/components/chat_message/chat_message.vue +++ b/src/components/chat_message/chat_message.vue @@ -74,9 +74,10 @@ > @@ -202,7 +203,7 @@ {{ ' ' }} { if (bottomedOutBeforeUpdate) { this.scrollDown() } }) }, + async replyStatus(newVal) { + await nextTick() // wait for changes to propagate to postStatusForm + this.$refs.postStatusForm.update() + }, $route: function () { this.startFetching() }, @@ -159,53 +190,9 @@ const Chat = { }, }, methods: { - onFilesDropped() { - this.$nextTick(() => { - this.handleResize() - }) - }, - handleVisibilityChange() { - this.$nextTick(() => { - if (!document.hidden && this.bottomedOut(BOTTOMED_OUT_OFFSET)) { - this.scrollDown({ forceRead: true }) - } - }) - }, - // "Sticks" scroll to bottom instead of top, helps with OSK resizing the viewport - handleResize(opts = {}) { - const { delayed = false } = opts - - if (delayed) { - setTimeout(() => { - this.handleResize({ ...opts, delayed: false }) - }, SAFE_RESIZE_TIME_OFFSET) - return - } - - this.$nextTick(() => { - const { offsetHeight = undefined } = getScrollPosition() - const diff = offsetHeight - this.lastScrollPosition.offsetHeight - if (diff !== 0 && !this.bottomedOut()) { - this.$nextTick(() => { - window.scrollBy({ top: -Math.trunc(diff) }) - }) - } - this.lastScrollPosition = getScrollPosition() - }) - }, - scrollDown(options = {}) { - const { behavior = 'auto', forceRead = false } = options - this.$nextTick(() => { - window.scrollTo({ - top: document.documentElement.scrollHeight, - behavior, - }) - }) - if (forceRead) { - this.readChat() - } - }, + // Actions async readChat() { + if (this.conversationId) return // Unsupported if (!this.maxId || document.hidden) { return } @@ -225,11 +212,17 @@ const Chat = { this.lastReadMessageId = this.maxId this.newMessageCount = 0 }, - bottomedOut(offset) { - return isBottomedOut(offset) - }, - reachedTop() { - return window.scrollY <= 0 + scrollDown(options = {}) { + const { behavior = 'auto', forceRead = false } = options + this.$nextTick(() => { + window.scrollTo({ + top: document.documentElement.scrollHeight, + behavior, + }) + }) + if (forceRead) { + this.readChat() + } }, cullOlder() { const maxIndex = this.messages.length @@ -248,44 +241,6 @@ const Chat = { this.messages = this.messages.slice(minIndex, maxIndex) }, - cullOlderCheck() { - window.setTimeout(() => { - if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) { - this.cullOlder() - } - }, 5000) - }, - handleScroll: throttle(function () { - if (!this.chat) { - return - } - this.lastScrollPosition = getScrollPosition() - - if (this.reachedTop()) { - this.fetchChat({ maxId: this.minId }) - } else if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) { - this.jumpToBottomButtonVisible = false - this.cullOlderCheck() - if (this.newMessageCount > 0) { - // Use a delay before marking as read to prevent situation where new messages - // arrive just as you're leaving the view and messages that you didn't actually - // get to see get marked as read. - window.setTimeout(() => { - // Don't mark as read if the element doesn't exist, user has left chat view - if (this.$el) this.readChat() - }, MARK_AS_READ_DELAY) - } - } else { - this.jumpToBottomButtonVisible = true - } - }, 200), - handleScrollUp(positionBeforeLoading) { - const positionAfterLoading = getScrollPosition() - - window.scrollTo({ - top: getNewTopPosition(positionBeforeLoading, positionAfterLoading), - }) - }, clear() { this.messages = this.messages.filter((m) => m.error) this.messagesIndex = this.messages.reduce( @@ -305,12 +260,31 @@ const Chat = { return } - const { data: messages } = await chatMessages({ - id: this.chat.id, - maxId, - sinceId: fetchLatest ? this.maxId : null, - credentials: useOAuthStore().token, - }) + let messages + if (this.isConversation) { + const [ + { data: status }, + { data: { ancestors, descendants } } + ] = await Promise.all([ + fetchStatus({ + id: this.statusId, + credentials: useOAuthStore().token, + }), + fetchConversation({ + id: this.statusId, + credentials: useOAuthStore().token, + }) + ]) + messages = [...ancestors, status, ...descendants] + } else { + const { data } = await chatMessages({ + id: this.chat.id, + maxId, + sinceId: fetchLatest ? this.maxId : null, + credentials: useOAuthStore().token, + }) + messages = data + } // Clear the current chat in case we're recovering from a ws connection loss. if (isFirstFetch) { @@ -338,20 +312,22 @@ const Chat = { } }, async startFetching() { - try { - const { data } = await getOrCreateChat({ - accountId: this.recipientId, - credentials: useOAuthStore().token, - }) - this.$store.commit('addNewUsers', [data.account]) - data.account = this.$store.getters.findUser(data.account.id) - this.chat = data - } catch (e) { - console.error('Error creating or getting a chat', e) - this.errorLoadingChat = true + if (!this.isConversation) { + try { + const { data } = await getOrCreateChat({ + accountId: this.recipientId, + credentials: useOAuthStore().token, + }) + this.$store.commit('addNewUsers', [data.account]) + data.account = this.$store.getters.findUser(data.account.id) + this.chat = data + } catch (e) { + console.error('Error creating or getting a chat', e) + this.errorLoadingChat = true + } } - if (this.chat) { + if (this.isConversation || this.chat) { this.$nextTick(() => { this.scrollDown({ forceRead: true }) }) @@ -365,33 +341,12 @@ const Chat = { ) this.fetchChat({ isFirstFetch: true }) }, - async deleteChatMessage({ chatId, messageId }) { - if (!this.testMode) - await deleteChatMessage({ - chatId, - messageId, - credentials: useOAuthStore().token, - }) - - this.messages = this.messages.filter((m) => m.id !== messageId) - delete this.messagesIndex[messageId] - - if (this.maxId === messageId) { - const lastMessage = maxBy(this.messages, 'id') - this.maxId = lastMessage.id - } - - if (this.minId === messageId) { - const firstMessage = minBy(this.messages, 'id') - this.minId = firstMessage.id - } - }, addMessages({ messages: newMessages }) { for (let i = 0; i < newMessages.length; i++) { const message = newMessages[i] // Sanity check - if (message.chat_id !== this.chat.id) { + if (!this.isConversation && (message.chat_id !== this.chat.id)) { console.warn( `Chat message doesn't belong to current chat (id: ${this.chat.id})!!`, message, @@ -428,14 +383,11 @@ const Chat = { } } }, - handleAttachmentPosting() { - this.$nextTick(() => { - this.handleResize() - // When the posting form size changes because of a media attachment, we need an extra resize - // to account for the potential delay in the DOM update. - this.scrollDown({ forceRead: true }) - }) + goBack() { + this.$router.back() }, + + // Optimistic posting (chats only) async sendMessage({ status, media, idempotencyKey }) { const params = { id: this.chat.id, @@ -515,12 +467,122 @@ const Chat = { fakeMessage.pending = false } }, - goBack() { - this.$router.push({ - name: 'chats', - params: { username: this.currentUser.screen_name }, + + // Checks + hasReachedTop() { + return window.scrollY <= 0 + }, + cullOlderCheck() { + if (this.conversationId) return + window.setTimeout(() => { + if (isBottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) { + this.cullOlder() + } + }, 5000) + }, + + // Event handlers + onPosted(data) { + this.explicitReplyStatus = null + this.$router.push({ name: 'conversation2', params: { statusId: data.id } }) + }, + handleVisibilityChange() { + this.$nextTick(() => { + if (!document.hidden && isBottomedOut(BOTTOMED_OUT_OFFSET)) { + this.scrollDown({ forceRead: true }) + } }) }, + onFilesDropped() { + this.$nextTick(() => { + this.handleResize() + }) + }, + handleResize(opts = {}) { + // "Sticks" scroll to bottom instead of top, helps with OSK resizing the viewport + const { delayed = false } = opts + + if (delayed) { + setTimeout(() => { + this.handleResize({ ...opts, delayed: false }) + }, SAFE_RESIZE_TIME_OFFSET) + return + } + + this.$nextTick(() => { + const { offsetHeight = undefined } = getScrollPosition() + const diff = offsetHeight - this.lastScrollPosition.offsetHeight + if (diff !== 0 && !isBottomedOut()) { + this.$nextTick(() => { + window.scrollBy({ top: -Math.trunc(diff) }) + }) + } + this.lastScrollPosition = getScrollPosition() + }) + }, + handleScroll: throttle(function () { + if (!this.chat) { + return + } + this.lastScrollPosition = getScrollPosition() + + if (this.hasReachedTop()) { + this.fetchChat({ maxId: this.minId }) + } else if (isBottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) { + this.jumpToBottomButtonVisible = false + this.cullOlderCheck() + if (this.newMessageCount > 0) { + // Use a delay before marking as read to prevent situation where new messages + // arrive just as you're leaving the view and messages that you didn't actually + // get to see get marked as read. + window.setTimeout(() => { + // Don't mark as read if the element doesn't exist, user has left chat view + if (this.$el) this.readChat() + }, MARK_AS_READ_DELAY) + } + } else { + this.jumpToBottomButtonVisible = true + } + }, 200), + handleScrollUp(positionBeforeLoading) { + const positionAfterLoading = getScrollPosition() + + window.scrollTo({ + top: getNewTopPosition(positionBeforeLoading, positionAfterLoading), + }) + }, + handleAttachmentPosting() { + this.$nextTick(() => { + this.handleResize() + // When the posting form size changes because of a media attachment, we need an extra resize + // to account for the potential delay in the DOM update. + this.scrollDown({ forceRead: true }) + }) + }, + + // Ugly + // TODO move to ChatMessage + async deleteChatMessage({ chatId, messageId }) { + if (!this.testMode) + await deleteChatMessage({ + chatId, + messageId, + credentials: useOAuthStore().token, + }) + + this.messages = this.messages.filter((m) => m.id !== messageId) + delete this.messagesIndex[messageId] + + if (this.maxId === messageId) { + const lastMessage = maxBy(this.messages, 'id') + this.maxId = lastMessage.id + } + + if (this.minId === messageId) { + const firstMessage = minBy(this.messages, 'id') + this.minId = firstMessage.id + } + }, }, } diff --git a/src/components/chat_view/chat_view.scss b/src/components/chat_view/chat_view.scss index 8af710ae1..8cc74567e 100644 --- a/src/components/chat_view/chat_view.scss +++ b/src/components/chat_view/chat_view.scss @@ -95,4 +95,11 @@ } } } + + .reply-to-text { + text-align: center; + line-height: 1.2; + padding-top: 0.5em; + margin-bottom: -0.5em; + } } diff --git a/src/components/chat_view/chat_view.vue b/src/components/chat_view/chat_view.vue index 0255347a9..aa2d90cbc 100644 --- a/src/components/chat_view/chat_view.vue +++ b/src/components/chat_view/chat_view.vue @@ -19,7 +19,17 @@ />
+ @@ -29,7 +39,10 @@ header-date :messages="messages" :pending-messages="pendingMessages" + :replied-id="replyStatus?.id" + :focused-id="statusId" @message-delete="deleteChatMessage" + @reply-requested="e => explicitReplyStatus = e" />
+
+
+ {{ explicitReplyStatus ? $t('status.reply_to_selected') : $t('status.reply_to_last') }} + +
+
diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js index 6e868a049..0bbf01e6a 100644 --- a/src/components/conversation/conversation.js +++ b/src/components/conversation/conversation.js @@ -114,7 +114,6 @@ const conversation = { inlineDivePosition: null, loadStatusError: null, unsuspendibleIds: new Set(), - explicitReplyStatus: null, } }, created() { @@ -130,12 +129,6 @@ const conversation = { const maxDepth = this.mergedConfig.maxDepthInThread - 2 return maxDepth >= 1 ? maxDepth : 1 }, - lastStatus() { - return this.conversation[this.conversation.length - 1] - }, - replyStatus() { - return this.explicitReplyStatus ?? this.lastStatus - }, streamingEnabled() { return ( this.mergedConfig.useStreamingApi && @@ -152,10 +145,7 @@ const conversation = { return this.displayStyle === 'tree' }, isLinearView() { - return this.displayStyle === 'linear' - }, - isChatView() { - return this.displayStyle === 'chat' + return this.displayStyle !== 'tree' }, shouldFadeAncestors() { return this.mergedConfig.conversationTreeFadeAncestors @@ -636,7 +626,6 @@ const conversation = { } }, onPosted(data) { - this.explicitReplyStatus = null if (this.isPage) { this.$router.push({ name: 'conversation', params: { id: data.id } }) } diff --git a/src/components/conversation/conversation.vue b/src/components/conversation/conversation.vue index ea1891da5..945be9749 100644 --- a/src/components/conversation/conversation.vue +++ b/src/components/conversation/conversation.vue @@ -176,7 +176,7 @@ />
@@ -202,52 +202,6 @@ />
-
- -
- -
{ + if (key === 'status') return + this.newStatus[key] = value + }) + }, onMentionsLineUpdate(e) { if (this.mentionsLineReadOnly) return // TODO diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue index c47e08a8b..b188d1b34 100644 --- a/src/components/post_status_form/post_status_form.vue +++ b/src/components/post_status_form/post_status_form.vue @@ -231,11 +231,12 @@ >
{{ $t('settings.conversation_display_linear_quick') }}
-
Date: Fri, 24 Jul 2026 16:44:35 +0300 Subject: [PATCH 2/5] a way to open thread in chat view --- .../quick_view_settings/quick_view_settings.js | 3 +-- src/components/status_action_buttons/action_button.js | 4 ++++ .../status_action_buttons/buttons_definitions.js | 11 +++++++++++ src/i18n/en.json | 1 + 4 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/components/quick_view_settings/quick_view_settings.js b/src/components/quick_view_settings/quick_view_settings.js index 32d7c5e2a..f809d241c 100644 --- a/src/components/quick_view_settings/quick_view_settings.js +++ b/src/components/quick_view_settings/quick_view_settings.js @@ -12,11 +12,10 @@ import { faBars, faFolderTree, faList, - faMessage, faWrench, } from '@fortawesome/free-solid-svg-icons' -library.add(faList, faFolderTree, faBars, faWrench, faMessage) +library.add(faList, faFolderTree, faBars, faWrench) const QuickViewSettings = { props: { diff --git a/src/components/status_action_buttons/action_button.js b/src/components/status_action_buttons/action_button.js index eed8cad9a..fe3cd3ed6 100644 --- a/src/components/status_action_buttons/action_button.js +++ b/src/components/status_action_buttons/action_button.js @@ -28,8 +28,10 @@ import { faShareAlt, faStar, faThumbtack, + faPencil, faTimes, faWrench, + faComments, } from '@fortawesome/free-solid-svg-icons' library.add( @@ -53,7 +55,9 @@ library.add( faEyeSlash, faEye, faThumbtack, + faPencil, faShareAlt, + faComments, faExternalLinkAlt, faHistory, ) diff --git a/src/components/status_action_buttons/buttons_definitions.js b/src/components/status_action_buttons/buttons_definitions.js index d8e4e3cd3..8dde74364 100644 --- a/src/components/status_action_buttons/buttons_definitions.js +++ b/src/components/status_action_buttons/buttons_definitions.js @@ -218,6 +218,17 @@ export const BUTTONS = [ ) }, }, + { + // ========= + // OPEN IN CHAT VIEW + // ========= + name: 'edit', + icon: 'comments', + label: 'status.open_in_chat_view', + action({ router, status }) { + router.push({ name: 'conversation2', params: { statusId: status.id } }) + }, + }, { // ========= // DELETE diff --git a/src/i18n/en.json b/src/i18n/en.json index 972575f9b..120054ddf 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -1647,6 +1647,7 @@ "delete_error": "Error deleting status: {0}", "edit": "Edit status", "edited_at": "(last edited {time})", + "open_in_chat_view": "Open in chat view", "pin": "Pin on profile", "unpin": "Unpin from profile", "pinned": "Pinned", From a07038e4bd360b5699a068eabfee57483fa1e3af Mon Sep 17 00:00:00 2001 From: Henry Jameson Date: Fri, 24 Jul 2026 17:11:36 +0300 Subject: [PATCH 3/5] pad chat messages from above and force full-height --- src/components/chat_view/chat_view.scss | 14 +++++++++++++- src/components/chat_view/chat_view.vue | 21 ++++++++++++--------- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/components/chat_view/chat_view.scss b/src/components/chat_view/chat_view.scss index 8cc74567e..c490546bc 100644 --- a/src/components/chat_view/chat_view.scss +++ b/src/components/chat_view/chat_view.scss @@ -1,6 +1,18 @@ .chat-view { display: flex; - height: 100%; + margin-bottom: -1em; + margin-top: -1em; + + .chat-list-wrapper { + display: flex; + flex-direction: column; + height: 100%; + } + + .top-spacer { + flex: 1 1 0; + min-height: 0; + } .chat-view-inner { height: auto; diff --git a/src/components/chat_view/chat_view.vue b/src/components/chat_view/chat_view.vue index aa2d90cbc..f24c892fd 100644 --- a/src/components/chat_view/chat_view.vue +++ b/src/components/chat_view/chat_view.vue @@ -35,15 +35,18 @@ />
- +
+
+ +