refactored chat into separate chat_view and chat_message_list

This commit is contained in:
Henry Jameson 2026-07-07 20:44:13 +03:00
commit 55917125a8
12 changed files with 127 additions and 96 deletions

View file

@ -294,7 +294,7 @@ export default (store) => {
name: 'chat', name: 'chat',
path: '/users/:username/chats/:recipient_id', path: '/users/:username/chats/:recipient_id',
component: defineAsyncComponent( component: defineAsyncComponent(
() => import('src/components/chat/chat.vue'), () => import('src/components/chat_view/chat_view.vue'),
), ),
meta: { dontScroll: false }, meta: { dontScroll: false },
beforeEnter: validateAuthenticatedRoute, beforeEnter: validateAuthenticatedRoute,

View file

@ -22,10 +22,9 @@ library.add(faTimes, faEllipsisH)
const ChatMessage = { const ChatMessage = {
name: 'ChatMessage', name: 'ChatMessage',
props: [ props: [
'author',
'edited', 'edited',
'noHeading', 'noHeading',
'chatViewItem', 'chatItem',
'hoveredMessageChain', 'hoveredMessageChain',
], ],
emits: ['hover'], emits: ['hover'],
@ -42,21 +41,26 @@ const ChatMessage = {
computed: { computed: {
// Returns HH:MM (hours and minutes) in local time. // Returns HH:MM (hours and minutes) in local time.
createdAt() { createdAt() {
const time = this.chatViewItem.data.created_at const time = this.chatItem.data.created_at
return time.toLocaleTimeString('en', { return time.toLocaleTimeString('en', {
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
hour12: false, hour12: false,
}) })
}, },
author() {
return this.$store.getters.findUser(
this.chatItem.data.account_id
)
},
isCurrentUser() { isCurrentUser() {
return this.message.account_id === this.currentUser.id return this.message.account_id === this.currentUser.id
}, },
message() { message() {
return this.chatViewItem.data return this.chatItem.data
}, },
isMessage() { isMessage() {
return this.chatViewItem.type === 'message' return this.chatItem.type === 'message'
}, },
messageForStatusContent() { messageForStatusContent() {
return { return {
@ -96,15 +100,15 @@ const ChatMessage = {
onHover(bool) { onHover(bool) {
this.$emit('hover', { this.$emit('hover', {
isHovered: bool, isHovered: bool,
messageChainId: this.chatViewItem.messageChainId, messageChainId: this.chatItem.messageChainId,
}) })
}, },
async deleteMessage() { async deleteMessage() {
const confirmed = window.confirm(this.$t('chats.delete_confirm')) const confirmed = window.confirm(this.$t('chats.delete_confirm'))
if (confirmed) { if (confirmed) {
await this.$store.dispatch('deleteChatMessage', { await this.$store.dispatch('deleteChatMessage', {
messageId: this.chatViewItem.data.id, messageId: this.chatItem.data.id,
chatId: this.chatViewItem.data.chat_id, chatId: this.chatItem.data.chat_id,
}) })
} }
this.hovered = false this.hovered = false

View file

@ -15,7 +15,7 @@
class="avatar-wrapper" class="avatar-wrapper"
> >
<UserPopover <UserPopover
v-if="chatViewItem.isHead" v-if="chatItem.isHead"
:user-id="author.id" :user-id="author.id"
> >
<UserAvatar <UserAvatar
@ -31,7 +31,7 @@
> >
<div <div
class="media status" class="media status"
:class="{ 'without-attachment': !hasAttachment, 'pending': chatViewItem.data.pending, 'error': chatViewItem.data.error }" :class="{ 'without-attachment': !hasAttachment, 'pending': chatItem.data.pending, 'error': chatItem.data.error }"
style="position: relative;" style="position: relative;"
@mouseenter="hovered = true" @mouseenter="hovered = true"
@mouseleave="hovered = false" @mouseleave="hovered = false"
@ -93,7 +93,7 @@
v-else v-else
class="chat-message-date-separator" class="chat-message-date-separator"
> >
<ChatMessageDate :date="chatViewItem.date" /> <ChatMessageDate :date="chatItem.date" />
</div> </div>
</template> </template>

View file

@ -0,0 +1,76 @@
import { throttle, orderBy, uniqueId } from 'lodash'
import { mapState as mapPiniaState } from 'pinia'
import { mapGetters, mapState } from 'vuex'
import ChatMessage from 'src/components/chat_message/chat_message.vue'
const ChatMessageList = {
components: {
ChatMessage,
},
props: {
messages: Array,
},
data() {
return {
hoveredMessageChainId: undefined,
}
},
computed: {
chatItems() {
const messages = orderBy(this.messages, ['pending', 'id'], ['asc', 'asc'])
return messages.reduceRight((acc, message, index) => {
const date = new Date(message.created_at)
const olderMessage = messages[index - 1]
const newerMessage = messages[index + 1]
const newerItem = acc[acc.length - 1]
const diff = message.created_at - (olderMessage?.created_at || 0)
const MAX_DIFF = 1000 * 60 // 5 minutes
const chatItem = {
type: 'message',
data: message,
date,
id: message.id,
isTail: true,
isHead: true,
}
if (newerItem == null) {
chatItem.messageChainId = uniqueId()
} else {
if (newerItem.type === 'date') {
chatItem.messageChainId = uniqueId()
} else if (newerItem.type === 'message') {
if (newerItem.data.account_id !== message.account_id) {
chatItem.messageChainId = uniqueId()
} else {
chatItem.messageChainId = newerItem.messageChainId
chatItem.isTail = false
newerItem.isHead = false
}
}
}
if (diff > MAX_DIFF || !olderMessage) {
return [...acc, chatItem, {
type: 'date',
date,
id: date.getTime().toString(),
}]
} else {
return [...acc, chatItem]
}
}, []).reverse()
}
},
methods: {
onMessageHover({ isHovered, messageChainId }) {
this.hoveredMessageChainId = isHovered ? messageChainId : undefined
},
}
}
export default ChatMessageList

View file

@ -0,0 +1,7 @@
.ChatMessageList {
padding: 0 0.8em;
height: 100%;
display: flex;
flex-direction: column;
justify-content: end;
}

View file

@ -0,0 +1,14 @@
<template>
<div class="ChatMessageList">
<ChatMessage
v-for="chatItem in chatItems"
:key="chatItem.id"
:chat-item="chatItem"
:hovered-message-chain="chatItem.messageChainId === hoveredMessageChainId"
@hover="onMessageHover"
/>
</div>
</template>
<script src="./chat_message_list.js"></script>
<style src="./chat_message_list.scss" lang="scss" />

View file

@ -2,8 +2,8 @@ import { throttle } from 'lodash'
import { mapState as mapPiniaState } from 'pinia' import { mapState as mapPiniaState } from 'pinia'
import { mapGetters, mapState } from 'vuex' import { mapGetters, mapState } from 'vuex'
import ChatMessage from 'src/components/chat_message/chat_message.vue'
import ChatTitle from 'src/components/chat_title/chat_title.vue' import ChatTitle from 'src/components/chat_title/chat_title.vue'
import ChatMessageList from 'src/components/chat_message_list/chat_message_list.vue'
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue' import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
import chatService from '../../services/chat_service/chat_service.js' import chatService from '../../services/chat_service/chat_service.js'
import { buildFakeMessage } from '../../services/chat_utils/chat_utils.js' import { buildFakeMessage } from '../../services/chat_utils/chat_utils.js'
@ -17,6 +17,7 @@ import {
import { useInterfaceStore } from 'src/stores/interface.js' import { useInterfaceStore } from 'src/stores/interface.js'
import { useOAuthStore } from 'src/stores/oauth.js' import { useOAuthStore } from 'src/stores/oauth.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { import {
chatMessages, chatMessages,
@ -38,10 +39,13 @@ const MAX_RETRIES = 10
const Chat = { const Chat = {
components: { components: {
ChatMessage, ChatMessageList,
ChatTitle, ChatTitle,
PostStatusForm, PostStatusForm,
}, },
props: {
messages: Array,
},
data() { data() {
return { return {
jumpToBottomButtonVisible: false, jumpToBottomButtonVisible: false,
@ -97,12 +101,11 @@ const Chat = {
return '' return ''
} }
}, },
chatViewItems() { chatMessages() {
return chatService.getView(this.currentChatMessageService?.messages) return this.currentChatMessageService?.messages
}, },
newMessageCount() { newMessageCount() {
return this.currentChatMessageService?.newMessageCount return this.currentChatMessageService?.newMessageCount
}, },
streamingEnabled() { streamingEnabled() {
return ( return (
@ -114,18 +117,18 @@ const Chat = {
'currentChat', 'currentChat',
'currentChatMessageService', 'currentChatMessageService',
'findOpenedChatByRecipientId', 'findOpenedChatByRecipientId',
'mergedConfig',
]), ]),
...mapPiniaState(useInterfaceStore, { ...mapPiniaState(useInterfaceStore, {
mobileLayout: (store) => store.layoutType === 'mobile', mobileLayout: (store) => store.layoutType === 'mobile',
}), }),
...mapPiniaState(useMergedConfigStore, ['mergedConfig']),
...mapState({ ...mapState({
mastoUserSocketStatus: (state) => state.api.mastoUserSocketStatus, mastoUserSocketStatus: (state) => state.api.mastoUserSocketStatus,
currentUser: (state) => state.users.currentUser, currentUser: (state) => state.users.currentUser,
}), }),
}, },
watch: { watch: {
chatViewItems() { chatMessages() {
// We don't want to scroll to the bottom on a new message when the user is viewing older messages. // We don't want to scroll to the bottom on a new message when the user is viewing older messages.
// Therefore we need to know whether the scroll position was at the bottom before the DOM update. // Therefore we need to know whether the scroll position was at the bottom before the DOM update.
const bottomedOutBeforeUpdate = this.bottomedOut(BOTTOMED_OUT_OFFSET) const bottomedOutBeforeUpdate = this.bottomedOut(BOTTOMED_OUT_OFFSET)

View file

@ -1,6 +1,6 @@
export default { export default {
name: 'Chat', name: 'Chat',
selector: '.chat-message-list', selector: '.ChatMessageList',
validInnerComponents: ['Text', 'Link', 'Icon', 'Avatar', 'ChatMessage'], validInnerComponents: ['Text', 'Link', 'Icon', 'Avatar', 'ChatMessage'],
defaultRules: [ defaultRules: [
{ {

View file

@ -25,29 +25,7 @@
/> />
</div> </div>
</div> </div>
<div <ChatMessageList :messages="chatMessages" />
class="chat-message-list message-list"
:style="{ height: scrollableContainerHeight }"
>
<template v-if="!errorLoadingChat">
<ChatMessage
v-for="chatViewItem in chatViewItems"
:key="chatViewItem.id"
:author="recipient"
:chat-view-item="chatViewItem"
:hovered-message-chain="chatViewItem.messageChainId === hoveredMessageChainId"
@hover="onMessageHover"
/>
</template>
<div
v-else
class="chat-loading-error"
>
<div class="alert error">
{{ $t('chats.error_loading_chat') }}
</div>
</div>
</div>
<div <div
ref="footer" ref="footer"
class="panel-body footer" class="panel-body footer"
@ -95,5 +73,5 @@
</div> </div>
</template> </template>
<script src="./chat.js"></script> <script src="./chat_view.js"></script>
<style src="./chat.scss" lang="scss" /> <style src="./chat_view.scss" lang="scss" />

View file

@ -159,60 +159,9 @@ const resetNewMessageCount = (storage) => {
storage.lastSeenMessageId = storage.maxId storage.lastSeenMessageId = storage.maxId
} }
// Inserts date separators and marks the head and tail if it's the chain of messages made by the same user
const getView = (items = []) => {
const messages = orderBy(items, ['pending', 'id'], ['asc', 'asc'])
return messages.reduceRight((acc, message, index) => {
const date = new Date(message.created_at)
const olderMessage = messages[index - 1]
const newerMessage = messages[index + 1]
const newerItem = acc[acc.length - 1]
const diff = message.created_at - (olderMessage?.created_at || 0)
const MAX_DIFF = 1000 * 60 // 5 minutes
const chatItem = {
type: 'message',
data: message,
date,
id: message.id,
isTail: true,
isHead: true,
}
if (newerItem == null) {
chatItem.messageChainId = uniqueId()
} else {
if (newerItem.type === 'date') {
chatItem.messageChainId = uniqueId()
} else if (newerItem.type === 'message') {
if (newerItem.data.account_id !== message.account_id) {
chatItem.messageChainId = uniqueId()
} else {
chatItem.messageChainId = newerItem.messageChainId
chatItem.isTail = false
newerItem.isHead = false
}
}
}
if (diff > MAX_DIFF || !olderMessage) {
return [...acc, chatItem, {
type: 'date',
date,
id: date.getTime().toString(),
}]
} else {
return [...acc, chatItem]
}
}, []).reverse()
}
const ChatService = { const ChatService = {
add, add,
empty, empty,
getView,
deleteMessage, deleteMessage,
cullOlderMessages, cullOlderMessages,
resetNewMessageCount, resetNewMessageCount,