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 { 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_CHATS_URL = '/api/v1/pleroma/chats'
const PLEROMA_CHAT_URL = (id) => `/api/v1/pleroma/chats/by-account-id/${id}` 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, url: PLEROMA_CHATS_URL,
credentials, credentials,
}).then(({ data }) => ({ }).then(({ data }) => ({
chatList: data.map(parseChat).filter((c) => c), data: data.map(parseChat).filter((c) => c),
})) }))
export const getOrCreateChat = ({ accountId, credentials }) => export const getOrCreateChat = ({ accountId, credentials }) =>
@ -23,7 +23,7 @@ export const getOrCreateChat = ({ accountId, credentials }) =>
url: PLEROMA_CHAT_URL(accountId), url: PLEROMA_CHAT_URL(accountId),
method: 'POST', method: 'POST',
credentials, credentials,
}) }).then(({ data }) => ({ data: parseChat(data) }))
export const chatMessages = ({ export const chatMessages = ({
id, id,
@ -36,7 +36,9 @@ export const chatMessages = ({
url: PLEROMA_CHAT_MESSAGES_URL(id, { maxId, sinceId, limit }), url: PLEROMA_CHAT_MESSAGES_URL(id, { maxId, sinceId, limit }),
method: 'GET', method: 'GET',
credentials, credentials,
}) }).then(({ data }) => ({
data: data.map(parseChatMessage).filter((c) => c),
}))
} }
export const sendChatMessage = ({ export const sendChatMessage = ({
@ -66,7 +68,9 @@ export const sendChatMessage = ({
payload, payload,
credentials, credentials,
headers, headers,
}) }).then(({ data }) => ({
data: parseChatMessage(data),
}))
} }
export const readChat = ({ id, lastReadId, credentials }) => 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 { useMergedConfigStore } from 'src/stores/merged_config.js'
import { library } from '@fortawesome/fontawesome-svg-core' 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 = { const ChatMessage = {
name: 'ChatMessage', name: 'ChatMessage',
props: [ props: ['edited', 'noHeading', 'chatItem', 'hoveredMessageChain'],
'edited',
'noHeading',
'chatItem',
'hoveredMessageChain',
],
emits: ['hover'], emits: ['hover'],
components: { components: {
Popover, Popover,

View file

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

View file

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

View file

@ -5,10 +5,10 @@
</template> </template>
<script> <script>
import localeService from 'src/services/locale/locale.service.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js' import { useMergedConfigStore } from 'src/stores/merged_config.js'
import localeService from 'src/services/locale/locale.service.js'
export default { export default {
name: 'Timeago', name: 'Timeago',
props: ['date', 'showTime'], props: ['date', 'showTime'],
@ -26,7 +26,7 @@ export default {
if (this.showTime) { if (this.showTime) {
return this.date.toLocaleTimeString( return this.date.toLocaleTimeString(
localeService.internalToBrowserLocale(this.$i18n.locale), localeService.internalToBrowserLocale(this.$i18n.locale),
{ hour12: this.time12hFormat, hour: 'numeric', minute: 'numeric' } { hour12: this.time12hFormat, hour: 'numeric', minute: 'numeric' },
) )
} else { } else {
return this.date.toLocaleDateString( 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 { mapState as mapPiniaState } from 'pinia'
import { mapGetters, mapState } from 'vuex' import { mapGetters, mapState } from 'vuex'
@ -10,6 +10,11 @@ const ChatMessageList = {
}, },
props: { props: {
messages: Array, messages: Array,
pendingMessages: {
type: Array,
required: false,
default: [],
},
headerDate: Boolean, headerDate: Boolean,
}, },
data() { data() {
@ -19,76 +24,87 @@ const ChatMessageList = {
}, },
computed: { computed: {
chatItems() { chatItems() {
const messages = orderBy(this.messages, ['pending', 'id'], ['asc', 'asc']) const messages = [
return messages.reduceRight((acc, message, index) => { ...orderBy(this.messages, ['pending', 'id'], ['asc', 'asc']),
const date = new Date(message.created_at) ...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 olderMessage = messages[index - 1]
const newerMessage = messages[index + 1] const newerMessage = messages[index + 1]
const newerItem = acc[acc.length - 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 = (() => { const dateDiffs = (() => {
if (olderMessage) { if (olderMessage) {
const newerDate = new Date(message.created_at) const newerDate = new Date(message.created_at)
const olderDate = new Date(olderMessage.created_at) const olderDate = new Date(olderMessage.created_at)
newerDate.setHours(0, 0, 0, 0) newerDate.setHours(0, 0, 0, 0)
olderDate.setHours(0, 0, 0, 0) olderDate.setHours(0, 0, 0, 0)
return newerDate.toISOString() !== olderDate.toISOString() 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()
} else { } else {
chatItem.messageChainId = newerItem.messageChainId return true
chatItem.isTail = false }
newerItem.isHead = false })()
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)) { if (diff > MAX_DIFF || (!olderMessage && this.headerDate)) {
return [...acc, chatItem, { return [
type: 'date', ...acc,
date, chatItem,
isDate: dateDiffs, {
isTime: diff > MAX_DIFF && !dateDiffs, type: 'date',
id: date.getTime().toString(), date,
}] isDate: dateDiffs,
} else { isTime: diff > MAX_DIFF && !dateDiffs,
return [...acc, chatItem] id: date.getTime().toString(),
} },
}, []).reverse() ]
} } else {
return [...acc, chatItem]
}
}, [])
.reverse()
},
}, },
methods: { methods: {
onMessageHover({ isHovered, messageChainId }) { onMessageHover({ isHovered, messageChainId }) {
this.hoveredMessageChainId = isHovered ? messageChainId : undefined this.hoveredMessageChainId = isHovered ? messageChainId : undefined
}, },
} },
} }
export default ChatMessageList export default ChatMessageList

View file

@ -1,9 +1,10 @@
import { throttle } from 'lodash' import { throttle } from 'lodash'
import { nextTick } from 'vue'
import { mapState as mapPiniaState } from 'pinia' import { mapState as mapPiniaState } from 'pinia'
import { mapGetters, mapState } from 'vuex' 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 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 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'
@ -16,12 +17,13 @@ import {
} from './chat_layout_utils.js' } from './chat_layout_utils.js'
import { useInterfaceStore } from 'src/stores/interface.js' import { useInterfaceStore } from 'src/stores/interface.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js' import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { import {
chatMessages, chatMessages,
getOrCreateChat, getOrCreateChat,
readChat,
sendChatMessage, sendChatMessage,
} from 'src/api/chats.js' } from 'src/api/chats.js'
import { WSConnectionStatus } from 'src/api/websocket.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 MARK_AS_READ_DELAY = 1500
const MAX_RETRIES = 10 const MAX_RETRIES = 10
const isConfirmation = (storage, message) => {
if (!message.idempotency_key) return
return storage.idempotencyKeyIndex[message.idempotency_key]
}
const Chat = { const Chat = {
components: { components: {
ChatMessageList, ChatMessageList,
ChatTitle, ChatTitle,
PostStatusForm, PostStatusForm,
}, },
props: {
messages: Array,
},
data() { data() {
return { return {
jumpToBottomButtonVisible: false, // Main info
hoveredMessageChainId: undefined, chat: null,
messages: [],
messagesIndex: {},
pendingMessages: [],
pendingMessagesIndex: {},
minId: undefined,
maxId: undefined,
// Unread stuff
newMessageCount: 0,
lastReadMessageId: null,
lastScrollPosition: {}, lastScrollPosition: {},
scrollableContainerHeight: '100%', jumpToBottomButtonVisible: false,
// Internal network stuff
fetcher: null,
errorLoadingChat: false, errorLoadingChat: false,
messageRetriers: {}, messageRetriers: {},
idempotencyKeyIndex: {},
} }
}, },
created() { created() {
@ -83,11 +101,10 @@ const Chat = {
this.handleVisibilityChange, this.handleVisibilityChange,
false, false,
) )
this.$store.dispatch('clearCurrentChat')
}, },
computed: { computed: {
recipient() { recipient() {
return this.currentChat && this.currentChat.account return this.chat?.account
}, },
recipientId() { recipientId() {
return this.$route.params.recipient_id return this.$route.params.recipient_id
@ -101,23 +118,12 @@ const Chat = {
return '' return ''
} }
}, },
chatMessages() {
return this.currentChatMessageService?.messages
},
newMessageCount() {
return this.currentChatMessageService?.newMessageCount
},
streamingEnabled() { streamingEnabled() {
return ( return (
this.mergedConfig.useStreamingApi && this.mergedConfig.useStreamingApi &&
this.mastoUserSocketStatus === WSConnectionStatus.JOINED this.mastoUserSocketStatus === WSConnectionStatus.JOINED
) )
}, },
...mapGetters([
'currentChat',
'currentChatMessageService',
'findOpenedChatByRecipientId',
]),
...mapPiniaState(useInterfaceStore, { ...mapPiniaState(useInterfaceStore, {
mobileLayout: (store) => store.layoutType === 'mobile', mobileLayout: (store) => store.layoutType === 'mobile',
}), }),
@ -128,7 +134,7 @@ const Chat = {
}), }),
}, },
watch: { watch: {
chatMessages() { messages() {
// 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)
@ -148,10 +154,6 @@ const Chat = {
}, },
}, },
methods: { 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() { onFilesDropped() {
this.$nextTick(() => { this.$nextTick(() => {
this.handleResize() this.handleResize()
@ -198,22 +200,22 @@ const Chat = {
this.readChat() this.readChat()
} }
}, },
readChat() { async readChat() {
if ( if (!this.maxId || document.hidden) {
!(
this.currentChatMessageService && this.currentChatMessageService.maxId
)
) {
return return
} }
if (document.hidden) { const lastReadId = this.maxId
return const isNewMessage = this.lastReadMessageId !== lastReadId
}
const lastReadId = this.currentChatMessageService.maxId if (!isNewMessage) return
this.$store.dispatch('readChat', {
id: this.currentChat.id, await readChat({
id: this.chat.id,
lastReadId, lastReadId,
credentials: useOAuthStore().token,
}) })
this.$store.commit('readChat', { id: this.chat.id, lastId: lastReadId })
}, },
bottomedOut(offset) { bottomedOut(offset) {
return isBottomedOut(offset) return isBottomedOut(offset)
@ -224,21 +226,32 @@ const Chat = {
cullOlderCheck() { cullOlderCheck() {
window.setTimeout(() => { window.setTimeout(() => {
if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) { if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) {
this.$store.dispatch( const maxIndex = this.messages.length
'cullOlderMessages', const minIndex = maxIndex - 50
this.currentChatMessageService.chatId, 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) }, 5000)
}, },
handleScroll: throttle(function () { handleScroll: throttle(function () {
this.lastScrollPosition = getScrollPosition() if (!this.chat) {
if (!this.currentChat) {
return return
} }
this.lastScrollPosition = getScrollPosition()
if (this.reachedTop()) { if (this.reachedTop()) {
this.fetchChat({ maxId: this.currentChatMessageService.minId }) this.fetchChat({ maxId: this.minId })
} else if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) { } else if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) {
this.jumpToBottomButtonVisible = false this.jumpToBottomButtonVisible = false
this.cullOlderCheck() this.cullOlderCheck()
@ -257,85 +270,124 @@ const Chat = {
}, 200), }, 200),
handleScrollUp(positionBeforeLoading) { handleScrollUp(positionBeforeLoading) {
const positionAfterLoading = getScrollPosition() const positionAfterLoading = getScrollPosition()
window.scrollTo({ window.scrollTo({
top: getNewTopPosition(positionBeforeLoading, positionAfterLoading), top: getNewTopPosition(positionBeforeLoading, positionAfterLoading),
}) })
}, },
fetchChat({ isFirstFetch = false, fetchLatest = false, maxId }) { clear() {
const chatMessageService = this.currentChatMessageService this.messages = this.messages.filter((m) => m.error)
if (!chatMessageService) { this.messagesIndex = this.messages.reduce(
return (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) { if (fetchLatest && this.streamingEnabled) {
return return
} }
const chatId = chatMessageService.chatId const { data: messages } = await chatMessages({
const fetchOlderMessages = !!maxId id: this.chat.id,
const sinceId = fetchLatest && chatMessageService.maxId
return chatMessages({
id: chatId,
maxId, maxId,
sinceId, sinceId: fetchLatest ? this.maxId : null,
credentials: useOAuthStore().token, 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() { async startFetching() {
let chat = this.findOpenedChatByRecipientId(this.recipientId) try {
if (!chat) { const { data } = await getOrCreateChat({
try { accountId: this.recipientId,
const { data } = await getOrCreateChat({ credentials: useOAuthStore().token,
accountId: this.recipientId, })
credentials: useOAuthStore().token, this.chat = data
}) } catch (e) {
chat = data console.error('Error creating or getting a chat', e)
} catch (e) { this.errorLoadingChat = true
console.error('Error creating or getting a chat', e)
this.errorLoadingChat = true
}
} }
if (chat) {
if (this.chat) {
this.$nextTick(() => { this.$nextTick(() => {
this.scrollDown({ forceRead: true }) this.scrollDown({ forceRead: true })
}) })
this.$store.dispatch('addOpenedChat', { chat })
this.doStartFetching() this.doStartFetching()
} }
}, },
doStartFetching() { doStartFetching() {
this.$store.dispatch('startFetchingCurrentChat', { this.fetcher = promiseInterval(
fetcher: () => () => this.fetchChat({ fetchLatest: true }),
promiseInterval(() => this.fetchChat({ fetchLatest: true }), 5000), 5000,
}) )
this.fetchChat({ isFirstFetch: true }) 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() { handleAttachmentPosting() {
this.$nextTick(() => { this.$nextTick(() => {
this.handleResize() this.handleResize()
@ -344,9 +396,9 @@ const Chat = {
this.scrollDown({ forceRead: true }) this.scrollDown({ forceRead: true })
}) })
}, },
sendMessage({ status, media, idempotencyKey }) { async sendMessage({ status, media, idempotencyKey }) {
const params = { const params = {
id: this.currentChat.id, id: this.chat.id,
content: status, content: status,
idempotencyKey, idempotencyKey,
} }
@ -357,69 +409,59 @@ const Chat = {
const fakeMessage = buildFakeMessage({ const fakeMessage = buildFakeMessage({
attachments: media, attachments: media,
chatId: this.currentChat.id, chatId: this.chat.id,
content: status, content: status,
userId: this.currentUser.id, userId: this.currentUser.id,
idempotencyKey, idempotencyKey,
}) })
this.$store this.pendingMessages.push(fakeMessage)
.dispatch('addChatMessages', { this.pendingMessagesIndex[idempotencyKey] = fakeMessage
chatId: this.currentChat.id,
messages: [fakeMessage], this.handleAttachmentPosting()
})
.then(() => {
this.handleAttachmentPosting()
})
return this.doSendMessage({ return this.doSendMessage({
params, params,
fakeMessage,
retriesLeft: MAX_RETRIES, retriesLeft: MAX_RETRIES,
}) })
}, },
doSendMessage({ params, fakeMessage, retriesLeft = MAX_RETRIES }) { async doSendMessage({ params, pendingId, retriesLeft = MAX_RETRIES }) {
if (retriesLeft <= 0) return if (retriesLeft <= 0) return
sendChatMessage({ try {
params, const { data } = await sendChatMessage({
credentials: useOAuthStore().token, ...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 {}
}) })
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() { goBack() {
this.$router.push({ this.$router.push({

View file

@ -27,7 +27,8 @@
</div> </div>
<ChatMessageList <ChatMessageList
header-date header-date
:messages="chatMessages" :messages="messages"
:pending-messages="pendingMessages"
/> />
<div <div
ref="footer" ref="footer"
@ -56,7 +57,7 @@
:disable-polls="true" :disable-polls="true"
:disable-quotes="true" :disable-quotes="true"
:disable-sensitivity-checkbox="true" :disable-sensitivity-checkbox="true"
:disable-submit="errorLoadingChat || !currentChat" :disable-submit="errorLoadingChat || !chat"
:disable-preview="true" :disable-preview="true"
:disable-draft="true" :disable-draft="true"
:optimistic-posting="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 as mapPiniaState } from 'pinia'
import { mapState } from 'vuex' 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 QuickFilterSettings from 'src/components/quick_filter_settings/quick_filter_settings.vue'
import QuickViewSettings from 'src/components/quick_view_settings/quick_view_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 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 { useInterfaceStore } from 'src/stores/interface'
import { useMergedConfigStore } from 'src/stores/merged_config.js' import { useMergedConfigStore } from 'src/stores/merged_config.js'

View file

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

View file

@ -12,8 +12,8 @@ import {
faBars, faBars,
faFolderTree, faFolderTree,
faList, faList,
faWrench,
faMessage, faMessage,
faWrench,
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
library.add(faList, faFolderTree, faBars, faWrench, faMessage) 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 chatService from '../services/chat_service/chat_service.js'
import { maybeShowChatNotification } from '../services/chat_utils/chat_utils.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 { promiseInterval } from '../services/promise_interval/promise_interval.js'
import { useOAuthStore } from 'src/stores/oauth.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 = () => ({ const emptyChatList = () => ({
data: [], data: [],
@ -21,8 +17,6 @@ const emptyChatList = () => ({
const defaultState = { const defaultState = {
chatList: emptyChatList(), chatList: emptyChatList(),
chatListFetcher: null, chatListFetcher: null,
openedChats: reactive({}),
openedChatMessageServices: reactive({}),
fetcher: undefined, fetcher: undefined,
currentChatId: null, currentChatId: null,
lastReadMessageId: null, lastReadMessageId: null,
@ -43,11 +37,6 @@ const unreadChatCount = (state) => {
const chatsModule = { const chatsModule = {
state: { ...defaultState }, state: { ...defaultState },
getters: { 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, sortedChatList,
unreadChatCount, unreadChatCount,
}, },
@ -65,8 +54,8 @@ const chatsModule = {
fetchChats({ dispatch, rootState }) { fetchChats({ dispatch, rootState }) {
return chats({ return chats({
credentials: useOAuthStore().token, credentials: useOAuthStore().token,
}).then(({ chatList }) => { }).then(({ data }) => {
dispatch('addNewChats', { chats: chatList }) dispatch('addNewChats', { chats: data })
return chats return chats
}) })
}, },
@ -86,45 +75,15 @@ const chatsModule = {
newChatMessageSideEffects, newChatMessageSideEffects,
}) })
}, },
updateChat({ commit }, { chat }) {
commit('updateChat', { chat })
},
// Opened Chats // Opened Chats
startFetchingCurrentChat({ dispatch }, { fetcher }) {
dispatch('setCurrentChatFetcher', { fetcher })
},
setCurrentChatFetcher({ commit }, { fetcher }) {
commit('setCurrentChatFetcher', { fetcher })
},
addOpenedChat({ commit, dispatch }, { chat }) { addOpenedChat({ commit, dispatch }, { chat }) {
commit('addOpenedChat', { dispatch, chat: parseChat(chat) }) commit('addOpenedChat', { dispatch, chat })
dispatch('addNewUsers', [chat.account]) dispatch('addNewUsers', [chat.account])
}, },
addChatMessages({ commit }, value) { addChatMessages({ commit }, value) {
commit('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({ rootState, commit }, value) {
deleteChatMessage({ deleteChatMessage({
...value, ...value,
@ -133,7 +92,6 @@ const chatsModule = {
commit('deleteChatMessage', { commit, ...value }) commit('deleteChatMessage', { commit, ...value })
}, },
resetChats({ commit, dispatch }) { resetChats({ commit, dispatch }) {
dispatch('clearCurrentChat')
commit('resetChats', { commit }) commit('resetChats', { commit })
}, },
clearOpenedChats({ commit }) { clearOpenedChats({ commit }) {
@ -142,9 +100,6 @@ const chatsModule = {
handleMessageError({ commit }, value) { handleMessageError({ commit }, value) {
commit('handleMessageError', { commit, ...value }) commit('handleMessageError', { commit, ...value })
}, },
cullOlderMessages({ commit }, chatId) {
commit('cullOlderMessages', chatId)
},
}, },
mutations: { mutations: {
setChatListFetcher(state, { fetcher }) { setChatListFetcher(state, { fetcher }) {
@ -154,21 +109,6 @@ const chatsModule = {
} }
state.chatListFetcher = fetcher && fetcher() 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 }) { setCurrentChatId(state, { chatId }) {
state.currentChatId = chatId state.currentChatId = chatId
}, },
@ -217,60 +157,16 @@ const chatsModule = {
state.chatList = emptyChatList() state.chatList = emptyChatList()
state.currentChatId = null state.currentChatId = null
commit('setChatListFetcher', { fetcher: undefined }) 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 }) { setChatsLoading(state, { value }) {
state.chats.loading = 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 }) { readChat(state, { id, lastReadId }) {
state.lastReadMessageId = lastReadId
const chat = getChatById(state, id) const chat = getChatById(state, id)
if (chat) { if (chat) {
chat.unread = 0 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' 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) => { const deleteMessage = (storage, messageId) => {
if (!storage) { if (!storage) {
return 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) => { const handleMessageError = (storage, fakeId, isRetry) => {
if (!storage) { if (!storage) {
return 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) => { const resetNewMessageCount = (storage) => {
if (!storage) { if (!storage) {
@ -160,12 +53,8 @@ const resetNewMessageCount = (storage) => {
} }
const ChatService = { const ChatService = {
add,
empty,
deleteMessage, deleteMessage,
cullOlderMessages,
resetNewMessageCount, resetNewMessageCount,
clear,
handleMessageError, handleMessageError,
} }