refactored chat handling into chat view component

This commit is contained in:
Henry Jameson 2026-07-08 17:47:50 +03:00
commit 26a2b4fca1
13 changed files with 315 additions and 455 deletions

View file

@ -1,6 +1,6 @@
import { paramsString, promisedRequest } from './helpers.js'
import { parseChat } from 'src/services/entity_normalizer/entity_normalizer.service.js'
import { parseChat, parseChatMessage } from 'src/services/entity_normalizer/entity_normalizer.service.js'
const PLEROMA_CHATS_URL = '/api/v1/pleroma/chats'
const PLEROMA_CHAT_URL = (id) => `/api/v1/pleroma/chats/by-account-id/${id}`
@ -15,7 +15,7 @@ export const chats = ({ credentials }) =>
url: PLEROMA_CHATS_URL,
credentials,
}).then(({ data }) => ({
chatList: data.map(parseChat).filter((c) => c),
data: data.map(parseChat).filter((c) => c),
}))
export const getOrCreateChat = ({ accountId, credentials }) =>
@ -23,7 +23,7 @@ export const getOrCreateChat = ({ accountId, credentials }) =>
url: PLEROMA_CHAT_URL(accountId),
method: 'POST',
credentials,
})
}).then(({ data }) => ({ data: parseChat(data) }))
export const chatMessages = ({
id,
@ -36,7 +36,9 @@ export const chatMessages = ({
url: PLEROMA_CHAT_MESSAGES_URL(id, { maxId, sinceId, limit }),
method: 'GET',
credentials,
})
}).then(({ data }) => ({
data: data.map(parseChatMessage).filter((c) => c),
}))
}
export const sendChatMessage = ({
@ -66,7 +68,9 @@ export const sendChatMessage = ({
payload,
credentials,
headers,
})
}).then(({ data }) => ({
data: parseChatMessage(data),
}))
}
export const readChat = ({ id, lastReadId, credentials }) =>

View file

@ -15,18 +15,13 @@ import { useInterfaceStore } from 'src/stores/interface'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faEllipsisH, faTimes } from '@fortawesome/free-solid-svg-icons'
import { faEllipsisH, faTimes, faCircleNotch } from '@fortawesome/free-solid-svg-icons'
library.add(faTimes, faEllipsisH)
library.add(faTimes, faEllipsisH, faCircleNotch)
const ChatMessage = {
name: 'ChatMessage',
props: [
'edited',
'noHeading',
'chatItem',
'hoveredMessageChain',
],
props: ['edited', 'noHeading', 'chatItem', 'hoveredMessageChain'],
emits: ['hover'],
components: {
Popover,

View file

@ -104,7 +104,7 @@
width: 100%;
}
.outgoing {
.-outgoing {
display: flex;
flex-flow: row wrap;
place-content: end flex-end;
@ -118,12 +118,16 @@
}
}
.incoming {
.-incoming {
.chat-message-menu {
left: 0.4rem;
}
}
.-pending {
color: red !important;
}
.chat-message-inner.with-media {
width: 100%;

View file

@ -8,7 +8,7 @@
>
<div
class="chat-message"
:class="[{ 'outgoing': isCurrentUser, 'incoming': !isCurrentUser }]"
:class="[{ '-outgoing': isCurrentUser, '-incoming': !isCurrentUser, '-pending': message.pending }]"
>
<div
v-if="!isCurrentUser"
@ -73,6 +73,7 @@
</div>
<StatusContent
class="message-content"
:class="{ faint: message.pending }"
:status="messageForStatusContent"
:full-content="true"
>
@ -80,6 +81,16 @@
<span
class="created-at"
>
<span
v-if="message.pending"
class="loading-spinner"
>
<FAIcon
class="fa-old-padding"
spin
icon="circle-notch"
/>
</span>
{{ createdAt }}
</span>
</template>

View file

@ -5,10 +5,10 @@
</template>
<script>
import localeService from 'src/services/locale/locale.service.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import localeService from 'src/services/locale/locale.service.js'
export default {
name: 'Timeago',
props: ['date', 'showTime'],
@ -26,7 +26,7 @@ export default {
if (this.showTime) {
return this.date.toLocaleTimeString(
localeService.internalToBrowserLocale(this.$i18n.locale),
{ hour12: this.time12hFormat, hour: 'numeric', minute: 'numeric' }
{ hour12: this.time12hFormat, hour: 'numeric', minute: 'numeric' },
)
} else {
return this.date.toLocaleDateString(

View file

@ -1,4 +1,4 @@
import { throttle, orderBy, uniqueId } from 'lodash'
import { orderBy, throttle, uniqueId } from 'lodash'
import { mapState as mapPiniaState } from 'pinia'
import { mapGetters, mapState } from 'vuex'
@ -10,6 +10,11 @@ const ChatMessageList = {
},
props: {
messages: Array,
pendingMessages: {
type: Array,
required: false,
default: [],
},
headerDate: Boolean,
},
data() {
@ -19,76 +24,87 @@ const ChatMessageList = {
},
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 messages = [
...orderBy(this.messages, ['pending', 'id'], ['asc', 'asc']),
...this.pendingMessages.map((m) => ({ ...m, pending: true })),
]
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 olderMessage = messages[index - 1]
const newerMessage = messages[index + 1]
const newerItem = acc[acc.length - 1]
const diff = olderMessage ? message.created_at - olderMessage.created_at : null
const diff = olderMessage
? message.created_at - olderMessage.created_at
: null
const MAX_DIFF = 1000 * 60 * 5 // 5 minutes
const MAX_DIFF = 1000 * 60 * 5 // 5 minutes
const dateDiffs = (() => {
if (olderMessage) {
const newerDate = new Date(message.created_at)
const olderDate = new Date(olderMessage.created_at)
const dateDiffs = (() => {
if (olderMessage) {
const newerDate = new Date(message.created_at)
const olderDate = new Date(olderMessage.created_at)
newerDate.setHours(0, 0, 0, 0)
olderDate.setHours(0, 0, 0, 0)
newerDate.setHours(0, 0, 0, 0)
olderDate.setHours(0, 0, 0, 0)
return newerDate.toISOString() !== olderDate.toISOString()
} else {
return true
}
})()
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()
return newerDate.toISOString() !== olderDate.toISOString()
} else {
chatItem.messageChainId = newerItem.messageChainId
chatItem.isTail = false
newerItem.isHead = false
return true
}
})()
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 && this.headerDate)) {
return [...acc, chatItem, {
type: 'date',
date,
isDate: dateDiffs,
isTime: diff > MAX_DIFF && !dateDiffs,
id: date.getTime().toString(),
}]
} else {
return [...acc, chatItem]
}
}, []).reverse()
}
if (diff > MAX_DIFF || (!olderMessage && this.headerDate)) {
return [
...acc,
chatItem,
{
type: 'date',
date,
isDate: dateDiffs,
isTime: diff > MAX_DIFF && !dateDiffs,
id: date.getTime().toString(),
},
]
} else {
return [...acc, chatItem]
}
}, [])
.reverse()
},
},
methods: {
onMessageHover({ isHovered, messageChainId }) {
this.hoveredMessageChainId = isHovered ? messageChainId : undefined
},
}
},
}
export default ChatMessageList

View file

@ -1,9 +1,10 @@
import { throttle } from 'lodash'
import { nextTick } from 'vue'
import { mapState as mapPiniaState } from 'pinia'
import { mapGetters, mapState } from 'vuex'
import ChatTitle from 'src/components/chat_title/chat_title.vue'
import ChatMessageList from 'src/components/chat_message_list/chat_message_list.vue'
import ChatTitle from 'src/components/chat_title/chat_title.vue'
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
import chatService from '../../services/chat_service/chat_service.js'
import { buildFakeMessage } from '../../services/chat_utils/chat_utils.js'
@ -16,12 +17,13 @@ import {
} from './chat_layout_utils.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import {
chatMessages,
getOrCreateChat,
readChat,
sendChatMessage,
} from 'src/api/chats.js'
import { WSConnectionStatus } from 'src/api/websocket.js'
@ -37,23 +39,39 @@ const SAFE_RESIZE_TIME_OFFSET = 100
const MARK_AS_READ_DELAY = 1500
const MAX_RETRIES = 10
const isConfirmation = (storage, message) => {
if (!message.idempotency_key) return
return storage.idempotencyKeyIndex[message.idempotency_key]
}
const Chat = {
components: {
ChatMessageList,
ChatTitle,
PostStatusForm,
},
props: {
messages: Array,
},
data() {
return {
jumpToBottomButtonVisible: false,
hoveredMessageChainId: undefined,
// Main info
chat: null,
messages: [],
messagesIndex: {},
pendingMessages: [],
pendingMessagesIndex: {},
minId: undefined,
maxId: undefined,
// Unread stuff
newMessageCount: 0,
lastReadMessageId: null,
lastScrollPosition: {},
scrollableContainerHeight: '100%',
jumpToBottomButtonVisible: false,
// Internal network stuff
fetcher: null,
errorLoadingChat: false,
messageRetriers: {},
idempotencyKeyIndex: {},
}
},
created() {
@ -83,11 +101,10 @@ const Chat = {
this.handleVisibilityChange,
false,
)
this.$store.dispatch('clearCurrentChat')
},
computed: {
recipient() {
return this.currentChat && this.currentChat.account
return this.chat?.account
},
recipientId() {
return this.$route.params.recipient_id
@ -101,23 +118,12 @@ const Chat = {
return ''
}
},
chatMessages() {
return this.currentChatMessageService?.messages
},
newMessageCount() {
return this.currentChatMessageService?.newMessageCount
},
streamingEnabled() {
return (
this.mergedConfig.useStreamingApi &&
this.mastoUserSocketStatus === WSConnectionStatus.JOINED
)
},
...mapGetters([
'currentChat',
'currentChatMessageService',
'findOpenedChatByRecipientId',
]),
...mapPiniaState(useInterfaceStore, {
mobileLayout: (store) => store.layoutType === 'mobile',
}),
@ -128,7 +134,7 @@ const Chat = {
}),
},
watch: {
chatMessages() {
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.
const bottomedOutBeforeUpdate = this.bottomedOut(BOTTOMED_OUT_OFFSET)
@ -148,10 +154,6 @@ const Chat = {
},
},
methods: {
// Used to animate the avatar near the first message of the message chain when any message belonging to the chain is hovered
onMessageHover({ isHovered, messageChainId }) {
this.hoveredMessageChainId = isHovered ? messageChainId : undefined
},
onFilesDropped() {
this.$nextTick(() => {
this.handleResize()
@ -198,22 +200,22 @@ const Chat = {
this.readChat()
}
},
readChat() {
if (
!(
this.currentChatMessageService && this.currentChatMessageService.maxId
)
) {
async readChat() {
if (!this.maxId || document.hidden) {
return
}
if (document.hidden) {
return
}
const lastReadId = this.currentChatMessageService.maxId
this.$store.dispatch('readChat', {
id: this.currentChat.id,
const lastReadId = this.maxId
const isNewMessage = this.lastReadMessageId !== lastReadId
if (!isNewMessage) return
await readChat({
id: this.chat.id,
lastReadId,
credentials: useOAuthStore().token,
})
this.$store.commit('readChat', { id: this.chat.id, lastId: lastReadId })
},
bottomedOut(offset) {
return isBottomedOut(offset)
@ -224,21 +226,32 @@ const Chat = {
cullOlderCheck() {
window.setTimeout(() => {
if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) {
this.$store.dispatch(
'cullOlderMessages',
this.currentChatMessageService.chatId,
)
const maxIndex = this.messages.length
const minIndex = maxIndex - 50
if (maxIndex <= 50) return
this.messages = sortBy(this.messages, ['id'])
this.minId = this.messages[minIndex].id
for (const message of this.messages) {
if (message.id < this.minId) {
delete this.messagesIndex[message.id]
delete this.idempotencyKeyIndex[message.idempotency_key]
}
}
this.messages = this.messages.slice(minIndex, maxIndex)
}
}, 5000)
},
handleScroll: throttle(function () {
this.lastScrollPosition = getScrollPosition()
if (!this.currentChat) {
if (!this.chat) {
return
}
this.lastScrollPosition = getScrollPosition()
if (this.reachedTop()) {
this.fetchChat({ maxId: this.currentChatMessageService.minId })
this.fetchChat({ maxId: this.minId })
} else if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) {
this.jumpToBottomButtonVisible = false
this.cullOlderCheck()
@ -257,85 +270,124 @@ const Chat = {
}, 200),
handleScrollUp(positionBeforeLoading) {
const positionAfterLoading = getScrollPosition()
window.scrollTo({
top: getNewTopPosition(positionBeforeLoading, positionAfterLoading),
})
},
fetchChat({ isFirstFetch = false, fetchLatest = false, maxId }) {
const chatMessageService = this.currentChatMessageService
if (!chatMessageService) {
return
}
clear() {
this.messages = this.messages.filter((m) => m.error)
this.messagesIndex = this.messages.reduce(
(acc, m) => ({
...acc,
[m.id]: m,
}),
{},
)
this.newMessageCount = 0
this.lastReadMessageId = null
this.minId = undefined
this.maxId = undefined
},
async fetchChat({ isFirstFetch = false, fetchLatest = false, maxId }) {
if (fetchLatest && this.streamingEnabled) {
return
}
const chatId = chatMessageService.chatId
const fetchOlderMessages = !!maxId
const sinceId = fetchLatest && chatMessageService.maxId
return chatMessages({
id: chatId,
const { data: messages } = await chatMessages({
id: this.chat.id,
maxId,
sinceId,
sinceId: fetchLatest ? this.maxId : null,
credentials: useOAuthStore().token,
}).then(({ data: messages }) => {
// Clear the current chat in case we're recovering from a ws connection loss.
if (isFirstFetch) {
chatService.clear(chatMessageService)
}
const positionBeforeUpdate = getScrollPosition()
this.$store
.dispatch('addChatMessages', { chatId, messages })
.then(() => {
this.$nextTick(() => {
if (fetchOlderMessages) {
this.handleScrollUp(positionBeforeUpdate)
}
// In vertical screens, the first batch of fetched messages may not always take the
// full height of the scrollable container.
// If this is the case, we want to fetch the messages until the scrollable container
// is fully populated so that the user has the ability to scroll up and load the history.
if (!isScrollable() && messages.length > 0) {
this.fetchChat({
maxId: this.currentChatMessageService.minId,
})
}
})
})
})
// Clear the current chat in case we're recovering from a ws connection loss.
if (isFirstFetch) {
this.clear()
}
const positionBeforeUpdate = getScrollPosition()
this.addMessages({ messages })
await nextTick()
const fetchOlderMessages = !!maxId
if (fetchOlderMessages) {
this.handleScrollUp(positionBeforeUpdate)
}
// In vertical screens, the first batch of fetched messages may not always take the
// full height of the scrollable container.
// If this is the case, we want to fetch the messages until the scrollable container
// is fully populated so that the user has the ability to scroll up and load the history.
if (!isScrollable() && messages.length > 0) {
this.fetchChat({
maxId: this.minId,
})
}
},
async startFetching() {
let chat = this.findOpenedChatByRecipientId(this.recipientId)
if (!chat) {
try {
const { data } = await getOrCreateChat({
accountId: this.recipientId,
credentials: useOAuthStore().token,
})
chat = data
} catch (e) {
console.error('Error creating or getting a chat', e)
this.errorLoadingChat = true
}
try {
const { data } = await getOrCreateChat({
accountId: this.recipientId,
credentials: useOAuthStore().token,
})
this.chat = data
} catch (e) {
console.error('Error creating or getting a chat', e)
this.errorLoadingChat = true
}
if (chat) {
if (this.chat) {
this.$nextTick(() => {
this.scrollDown({ forceRead: true })
})
this.$store.dispatch('addOpenedChat', { chat })
this.doStartFetching()
}
},
doStartFetching() {
this.$store.dispatch('startFetchingCurrentChat', {
fetcher: () =>
promiseInterval(() => this.fetchChat({ fetchLatest: true }), 5000),
})
this.fetcher = promiseInterval(
() => this.fetchChat({ fetchLatest: true }),
5000,
)
this.fetchChat({ isFirstFetch: true })
},
addMessages({ messages: newMessages }) {
for (let i = 0; i < newMessages.length; i++) {
const message = newMessages[i]
// Sanity check
if (message.chat_id !== this.chat.id) {
console.warn(`Chat message doesn't belong to current chat (id: ${this.chat.id})!!`, message)
return
}
// Clear any known pending messages
if (message.idempotency_key) {
if (this.pendingMessagesIndex[message.idempotencyKeyIndex]) {
delete this.pendingMessagesIndex[message.idempotencyKeyIndex]
this.pendingMessages = this.pendingMessages.filter(({ idempotency_key }) => idempotency_key !== message.idempotency_key)
}
}
if (!this.minId || (!message.pending && message.id < this.minId)) {
this.minId = message.id
}
if (!this.maxId || message.id > this.maxId) {
this.maxId = message.id
}
if (!this.messagesIndex[message.id] && !isConfirmation(this, message)) {
if (this.lastSeenMessageId < message.id) {
this.newMessageCount++
}
this.messagesIndex[message.id] = message
this.messages.push(this.messagesIndex[message.id])
this.idempotencyKeyIndex[message.idempotency_key] = true
}
}
},
handleAttachmentPosting() {
this.$nextTick(() => {
this.handleResize()
@ -344,9 +396,9 @@ const Chat = {
this.scrollDown({ forceRead: true })
})
},
sendMessage({ status, media, idempotencyKey }) {
async sendMessage({ status, media, idempotencyKey }) {
const params = {
id: this.currentChat.id,
id: this.chat.id,
content: status,
idempotencyKey,
}
@ -357,69 +409,59 @@ const Chat = {
const fakeMessage = buildFakeMessage({
attachments: media,
chatId: this.currentChat.id,
chatId: this.chat.id,
content: status,
userId: this.currentUser.id,
idempotencyKey,
})
this.$store
.dispatch('addChatMessages', {
chatId: this.currentChat.id,
messages: [fakeMessage],
})
.then(() => {
this.handleAttachmentPosting()
})
this.pendingMessages.push(fakeMessage)
this.pendingMessagesIndex[idempotencyKey] = fakeMessage
this.handleAttachmentPosting()
return this.doSendMessage({
params,
fakeMessage,
retriesLeft: MAX_RETRIES,
})
},
doSendMessage({ params, fakeMessage, retriesLeft = MAX_RETRIES }) {
async doSendMessage({ params, pendingId, retriesLeft = MAX_RETRIES }) {
if (retriesLeft <= 0) return
sendChatMessage({
params,
credentials: useOAuthStore().token,
})
.then(({ data }) => {
this.$store.dispatch('addChatMessages', {
chatId: this.currentChat.id,
updateMaxId: false,
messages: [{ ...data, fakeId: fakeMessage.id }],
})
return data
})
.catch((error) => {
console.error('Error sending message', error)
this.$store.dispatch('handleMessageError', {
chatId: this.currentChat.id,
fakeId: fakeMessage.id,
isRetry: retriesLeft !== MAX_RETRIES,
})
if (
(error.statusCode >= 500 && error.statusCode < 600) ||
error.message === 'Failed to fetch'
) {
this.messageRetriers[fakeMessage.id] = setTimeout(
() => {
this.doSendMessage({
params,
fakeMessage,
retriesLeft: retriesLeft - 1,
})
},
1000 * 2 ** (MAX_RETRIES - retriesLeft),
)
}
return {}
try {
const { data } = await sendChatMessage({
...params,
credentials: useOAuthStore().token,
})
return Promise.resolve(fakeMessage)
this.addMessages({
messages: [{ ...data }],
})
} catch (error) {
if (error.name !== 'StatusCodeError') throw error
console.error('Error sending message', error)
this.handleMessageError({
chatId: this.chat.id,
isRetry: retriesLeft !== MAX_RETRIES,
})
if (
(error.statusCode >= 500 && error.statusCode < 600) ||
error.message === 'Failed to fetch'
) {
this.messageRetriers[fakeMessage.id] = setTimeout(
() => {
this.doSendMessage({
params,
fakeMessage,
retriesLeft: retriesLeft - 1,
})
},
1000 * 2 ** (MAX_RETRIES - retriesLeft),
)
}
}
},
goBack() {
this.$router.push({

View file

@ -27,7 +27,8 @@
</div>
<ChatMessageList
header-date
:messages="chatMessages"
:messages="messages"
:pending-messages="pendingMessages"
/>
<div
ref="footer"
@ -56,7 +57,7 @@
:disable-polls="true"
:disable-quotes="true"
:disable-sensitivity-checkbox="true"
:disable-submit="errorLoadingChat || !currentChat"
:disable-submit="errorLoadingChat || !chat"
:disable-preview="true"
:disable-draft="true"
:optimistic-posting="true"

View file

@ -2,10 +2,10 @@ import { clone, filter, findIndex, get, reduce } from 'lodash'
import { mapState as mapPiniaState } from 'pinia'
import { mapState } from 'vuex'
import ChatMessageList from 'src/components/chat_message_list/chat_message_list.vue'
import QuickFilterSettings from 'src/components/quick_filter_settings/quick_filter_settings.vue'
import QuickViewSettings from 'src/components/quick_view_settings/quick_view_settings.vue'
import ThreadTree from 'src/components/thread_tree/thread_tree.vue'
import ChatMessageList from 'src/components/chat_message_list/chat_message_list.vue'
import { useInterfaceStore } from 'src/stores/interface'
import { useMergedConfigStore } from 'src/stores/merged_config.js'

View file

@ -596,16 +596,18 @@ const PostStatusForm = {
? this.postHandler
: statusPoster.postStatus
postHandler(postingOptions).then((data) => {
if (!data.error) {
postHandler(postingOptions)
.then((data) => {
this.abandonDraft()
this.clearStatus()
this.$emit('posted', data)
} else {
})
.catch((error) => {
this.error = data.error
}
this.posting = false
})
})
.finally(() => {
this.posting = false
})
},
previewStatus() {
if (this.emptyStatus && this.newStatus.spoilerText.trim() === '') {

View file

@ -12,8 +12,8 @@ import {
faBars,
faFolderTree,
faList,
faWrench,
faMessage,
faWrench,
} from '@fortawesome/free-solid-svg-icons'
library.add(faList, faFolderTree, faBars, faWrench, faMessage)

View file

@ -3,15 +3,11 @@ import { reactive } from 'vue'
import chatService from '../services/chat_service/chat_service.js'
import { maybeShowChatNotification } from '../services/chat_utils/chat_utils.js'
import {
parseChat,
parseChatMessage,
} from '../services/entity_normalizer/entity_normalizer.service.js'
import { promiseInterval } from '../services/promise_interval/promise_interval.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { chats, deleteChatMessage, readChat } from 'src/api/chats.js'
import { chats, deleteChatMessage } from 'src/api/chats.js'
const emptyChatList = () => ({
data: [],
@ -21,8 +17,6 @@ const emptyChatList = () => ({
const defaultState = {
chatList: emptyChatList(),
chatListFetcher: null,
openedChats: reactive({}),
openedChatMessageServices: reactive({}),
fetcher: undefined,
currentChatId: null,
lastReadMessageId: null,
@ -43,11 +37,6 @@ const unreadChatCount = (state) => {
const chatsModule = {
state: { ...defaultState },
getters: {
currentChat: (state) => state.openedChats[state.currentChatId],
currentChatMessageService: (state) =>
state.openedChatMessageServices[state.currentChatId],
findOpenedChatByRecipientId: (state) => (recipientId) =>
find(state.openedChats, (c) => c.account.id === recipientId),
sortedChatList,
unreadChatCount,
},
@ -65,8 +54,8 @@ const chatsModule = {
fetchChats({ dispatch, rootState }) {
return chats({
credentials: useOAuthStore().token,
}).then(({ chatList }) => {
dispatch('addNewChats', { chats: chatList })
}).then(({ data }) => {
dispatch('addNewChats', { chats: data })
return chats
})
},
@ -86,45 +75,15 @@ const chatsModule = {
newChatMessageSideEffects,
})
},
updateChat({ commit }, { chat }) {
commit('updateChat', { chat })
},
// Opened Chats
startFetchingCurrentChat({ dispatch }, { fetcher }) {
dispatch('setCurrentChatFetcher', { fetcher })
},
setCurrentChatFetcher({ commit }, { fetcher }) {
commit('setCurrentChatFetcher', { fetcher })
},
addOpenedChat({ commit, dispatch }, { chat }) {
commit('addOpenedChat', { dispatch, chat: parseChat(chat) })
commit('addOpenedChat', { dispatch, chat })
dispatch('addNewUsers', [chat.account])
},
addChatMessages({ commit }, value) {
commit('addChatMessages', { commit, ...value })
},
resetChatNewMessageCount({ commit }, value) {
commit('resetChatNewMessageCount', value)
},
clearCurrentChat({ commit }) {
commit('setCurrentChatId', { chatId: undefined })
commit('setCurrentChatFetcher', { fetcher: undefined })
},
readChat({ rootState, commit, dispatch }, { id, lastReadId }) {
const isNewMessage = rootState.chats.lastReadMessageId !== lastReadId
dispatch('resetChatNewMessageCount')
commit('readChat', { id, lastReadId })
if (isNewMessage) {
readChat({
id,
lastReadId,
credentials: useOAuthStore().token,
})
}
},
deleteChatMessage({ rootState, commit }, value) {
deleteChatMessage({
...value,
@ -133,7 +92,6 @@ const chatsModule = {
commit('deleteChatMessage', { commit, ...value })
},
resetChats({ commit, dispatch }) {
dispatch('clearCurrentChat')
commit('resetChats', { commit })
},
clearOpenedChats({ commit }) {
@ -142,9 +100,6 @@ const chatsModule = {
handleMessageError({ commit }, value) {
commit('handleMessageError', { commit, ...value })
},
cullOlderMessages({ commit }, chatId) {
commit('cullOlderMessages', chatId)
},
},
mutations: {
setChatListFetcher(state, { fetcher }) {
@ -154,21 +109,6 @@ const chatsModule = {
}
state.chatListFetcher = fetcher && fetcher()
},
setCurrentChatFetcher(state, { fetcher }) {
const prevFetcher = state.fetcher
if (prevFetcher) {
prevFetcher.stop()
}
state.fetcher = fetcher && fetcher()
},
addOpenedChat(state, { chat }) {
state.currentChatId = chat.id
state.openedChats[chat.id] = chat
if (!state.openedChatMessageServices[chat.id]) {
state.openedChatMessageServices[chat.id] = chatService.empty(chat.id)
}
},
setCurrentChatId(state, { chatId }) {
state.currentChatId = chatId
},
@ -217,60 +157,16 @@ const chatsModule = {
state.chatList = emptyChatList()
state.currentChatId = null
commit('setChatListFetcher', { fetcher: undefined })
for (const chatId in state.openedChats) {
chatService.clear(state.openedChatMessageServices[chatId])
delete state.openedChats[chatId]
delete state.openedChatMessageServices[chatId]
}
},
setChatsLoading(state, { value }) {
state.chats.loading = value
},
addChatMessages(state, { chatId, messages, updateMaxId }) {
const chatMessageService = state.openedChatMessageServices[chatId]
if (chatMessageService) {
chatService.add(chatMessageService, {
messages: messages.map(parseChatMessage),
updateMaxId,
})
}
},
deleteChatMessage(state, { chatId, messageId }) {
const chatMessageService = state.openedChatMessageServices[chatId]
if (chatMessageService) {
chatService.deleteMessage(chatMessageService, messageId)
}
},
resetChatNewMessageCount(state) {
const chatMessageService =
state.openedChatMessageServices[state.currentChatId]
chatService.resetNewMessageCount(chatMessageService)
},
// Used when a connection loss occurs
clearOpenedChats(state) {
const currentChatId = state.currentChatId
for (const chatId in state.openedChats) {
if (currentChatId !== chatId) {
chatService.clear(state.openedChatMessageServices[chatId])
delete state.openedChats[chatId]
delete state.openedChatMessageServices[chatId]
}
}
},
readChat(state, { id, lastReadId }) {
state.lastReadMessageId = lastReadId
const chat = getChatById(state, id)
if (chat) {
chat.unread = 0
}
},
handleMessageError(state, { chatId, fakeId, isRetry }) {
const chatMessageService = state.openedChatMessageServices[chatId]
chatService.handleMessageError(chatMessageService, fakeId, isRetry)
},
cullOlderMessages(state, chatId) {
chatService.cullOlderMessages(state.openedChatMessageServices[chatId])
},
},
}

View file

@ -1,39 +1,5 @@
import { maxBy, minBy, orderBy, sortBy, uniqueId } from 'lodash'
const empty = (chatId) => {
return {
idIndex: {},
idempotencyKeyIndex: {},
messages: [],
newMessageCount: 0,
lastSeenMessageId: '0',
chatId,
minId: undefined,
maxId: undefined,
}
}
const clear = (storage) => {
const failedMessageIds = []
for (const message of storage.messages) {
if (message.error) {
failedMessageIds.push(message.id)
} else {
delete storage.idIndex[message.id]
delete storage.idempotencyKeyIndex[message.idempotency_key]
}
}
storage.messages = storage.messages.filter((m) =>
failedMessageIds.includes(m.id),
)
storage.newMessageCount = 0
storage.lastSeenMessageId = '0'
storage.minId = undefined
storage.maxId = undefined
}
const deleteMessage = (storage, messageId) => {
if (!storage) {
return
@ -52,22 +18,6 @@ const deleteMessage = (storage, messageId) => {
}
}
const cullOlderMessages = (storage) => {
const maxIndex = storage.messages.length
const minIndex = maxIndex - 50
if (maxIndex <= 50) return
storage.messages = sortBy(storage.messages, ['id'])
storage.minId = storage.messages[minIndex].id
for (const message of storage.messages) {
if (message.id < storage.minId) {
delete storage.idIndex[message.id]
delete storage.idempotencyKeyIndex[message.idempotency_key]
}
}
storage.messages = storage.messages.slice(minIndex, maxIndex)
}
const handleMessageError = (storage, fakeId, isRetry) => {
if (!storage) {
return
@ -93,63 +43,6 @@ const handleMessageError = (storage, fakeId, isRetry) => {
}
}
const add = (storage, { messages: newMessages, updateMaxId = true }) => {
if (!storage) {
return
}
for (let i = 0; i < newMessages.length; i++) {
const message = newMessages[i]
// sanity check
if (message.chat_id !== storage.chatId) {
return
}
if (message.fakeId) {
const fakeMessage = storage.idIndex[message.fakeId]
if (fakeMessage) {
// In case the same id exists (chat update before POST response)
// make sure to remove the older duplicate message.
if (storage.idIndex[message.id]) {
delete storage.idIndex[message.id]
storage.messages = storage.messages.filter(
(msg) => msg.id !== message.id,
)
}
Object.assign(fakeMessage, message, { error: false })
delete fakeMessage.fakeId
storage.idIndex[fakeMessage.id] = fakeMessage
delete storage.idIndex[message.fakeId]
return
}
}
if (!storage.minId || (!message.pending && message.id < storage.minId)) {
storage.minId = message.id
}
if (!storage.maxId || message.id > storage.maxId) {
if (updateMaxId) {
storage.maxId = message.id
}
}
if (!storage.idIndex[message.id] && !isConfirmation(storage, message)) {
if (storage.lastSeenMessageId < message.id) {
storage.newMessageCount++
}
storage.idIndex[message.id] = message
storage.messages.push(storage.idIndex[message.id])
storage.idempotencyKeyIndex[message.idempotency_key] = true
}
}
}
const isConfirmation = (storage, message) => {
if (!message.idempotency_key) return
return storage.idempotencyKeyIndex[message.idempotency_key]
}
const resetNewMessageCount = (storage) => {
if (!storage) {
@ -160,12 +53,8 @@ const resetNewMessageCount = (storage) => {
}
const ChatService = {
add,
empty,
deleteMessage,
cullOlderMessages,
resetNewMessageCount,
clear,
handleMessageError,
}