forked from AkkomaGang/akkoma-fe
Optimistic message sending for chat
This commit is contained in:
parent
148789767a
commit
e798e9a417
13 changed files with 206 additions and 44 deletions
|
@ -12,6 +12,7 @@ import {
|
|||
faChevronDown,
|
||||
faChevronLeft
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import { buildFakeMessage } from '../../services/chat_utils/chat_utils.js'
|
||||
|
||||
library.add(
|
||||
faChevronDown,
|
||||
|
@ -22,6 +23,7 @@ const BOTTOMED_OUT_OFFSET = 10
|
|||
const JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET = 150
|
||||
const SAFE_RESIZE_TIME_OFFSET = 100
|
||||
const MARK_AS_READ_DELAY = 1500
|
||||
const MAX_RETRIES = 10
|
||||
|
||||
const Chat = {
|
||||
components: {
|
||||
|
@ -35,7 +37,8 @@ const Chat = {
|
|||
hoveredMessageChainId: undefined,
|
||||
lastScrollPosition: {},
|
||||
scrollableContainerHeight: '100%',
|
||||
errorLoadingChat: false
|
||||
errorLoadingChat: false,
|
||||
messageRetriers: {}
|
||||
}
|
||||
},
|
||||
created () {
|
||||
|
@ -219,7 +222,10 @@ const Chat = {
|
|||
if (!(this.currentChatMessageService && this.currentChatMessageService.maxId)) { return }
|
||||
if (document.hidden) { return }
|
||||
const lastReadId = this.currentChatMessageService.maxId
|
||||
this.$store.dispatch('readChat', { id: this.currentChat.id, lastReadId })
|
||||
this.$store.dispatch('readChat', {
|
||||
id: this.currentChat.id,
|
||||
lastReadId
|
||||
})
|
||||
},
|
||||
bottomedOut (offset) {
|
||||
return isBottomedOut(this.$refs.scrollable, offset)
|
||||
|
@ -309,42 +315,74 @@ const Chat = {
|
|||
})
|
||||
this.fetchChat({ isFirstFetch: true })
|
||||
},
|
||||
sendMessage ({ status, media }) {
|
||||
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.
|
||||
setTimeout(() => {
|
||||
this.updateScrollableContainerHeight()
|
||||
}, SAFE_RESIZE_TIME_OFFSET)
|
||||
this.scrollDown({ forceRead: true })
|
||||
})
|
||||
},
|
||||
sendMessage ({ status, media, idempotencyKey }) {
|
||||
const params = {
|
||||
id: this.currentChat.id,
|
||||
content: status
|
||||
content: status,
|
||||
idempotencyKey
|
||||
}
|
||||
|
||||
if (media[0]) {
|
||||
params.mediaId = media[0].id
|
||||
}
|
||||
|
||||
return this.backendInteractor.sendChatMessage(params)
|
||||
const fakeMessage = buildFakeMessage({
|
||||
attachments: media,
|
||||
chatId: this.currentChat.id,
|
||||
content: status,
|
||||
userId: this.currentUser.id,
|
||||
idempotencyKey
|
||||
})
|
||||
|
||||
this.$store.dispatch('addChatMessages', {
|
||||
chatId: this.currentChat.id,
|
||||
messages: [fakeMessage]
|
||||
}).then(() => {
|
||||
this.handleAttachmentPosting()
|
||||
})
|
||||
|
||||
return this.doSendMessage({ params, fakeMessage, retriesLeft: MAX_RETRIES })
|
||||
},
|
||||
doSendMessage ({ params, fakeMessage, retriesLeft = MAX_RETRIES }) {
|
||||
if (retriesLeft <= 0) return
|
||||
|
||||
this.backendInteractor.sendChatMessage(params)
|
||||
.then(data => {
|
||||
this.$store.dispatch('addChatMessages', {
|
||||
chatId: this.currentChat.id,
|
||||
messages: [data],
|
||||
updateMaxId: false
|
||||
}).then(() => {
|
||||
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.
|
||||
setTimeout(() => {
|
||||
this.updateScrollableContainerHeight()
|
||||
}, SAFE_RESIZE_TIME_OFFSET)
|
||||
this.scrollDown({ forceRead: true })
|
||||
})
|
||||
updateMaxId: false,
|
||||
messages: [{ ...data, fakeId: fakeMessage.id }]
|
||||
})
|
||||
|
||||
return data
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error sending message', error)
|
||||
return {
|
||||
error: this.$t('chats.error_sending_message')
|
||||
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)
|
||||
},
|
||||
goBack () {
|
||||
this.$router.push({ name: 'chats', params: { username: this.currentUser.screen_name } })
|
||||
|
|
|
@ -80,6 +80,7 @@
|
|||
:disable-sensitivity-checkbox="true"
|
||||
:disable-submit="errorLoadingChat || !currentChat"
|
||||
:disable-preview="true"
|
||||
:optimistic-posting="true"
|
||||
:post-handler="sendMessage"
|
||||
:submit-on-enter="!mobileLayout"
|
||||
:preserve-focus="!mobileLayout"
|
||||
|
|
|
@ -101,6 +101,19 @@
|
|||
}
|
||||
}
|
||||
|
||||
.pending {
|
||||
.status-content.media-body, .created-at {
|
||||
color: var(--faint);
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
.status-content.media-body, .created-at {
|
||||
color: $fallback--cRed;
|
||||
color: var(--badgeNotification, $fallback--cRed);
|
||||
}
|
||||
}
|
||||
|
||||
.incoming {
|
||||
a {
|
||||
color: var(--chatMessageIncomingLink, $fallback--link);
|
||||
|
|
|
@ -32,7 +32,7 @@
|
|||
>
|
||||
<div
|
||||
class="media status"
|
||||
:class="{ 'without-attachment': !hasAttachment }"
|
||||
:class="{ 'without-attachment': !hasAttachment, 'pending': chatViewItem.data.pending, 'error': chatViewItem.data.error }"
|
||||
style="position: relative"
|
||||
@mouseenter="hovered = true"
|
||||
@mouseleave="hovered = false"
|
||||
|
|
|
@ -75,7 +75,8 @@ const PostStatusForm = {
|
|||
'autoFocus',
|
||||
'fileLimit',
|
||||
'submitOnEnter',
|
||||
'emojiPickerPlacement'
|
||||
'emojiPickerPlacement',
|
||||
'optimisticPosting'
|
||||
],
|
||||
components: {
|
||||
MediaUpload,
|
||||
|
@ -272,7 +273,7 @@ const PostStatusForm = {
|
|||
if (this.preview) this.previewStatus()
|
||||
},
|
||||
async postStatus (event, newStatus, opts = {}) {
|
||||
if (this.posting) { return }
|
||||
if (this.posting && !this.optimisticPosting) { return }
|
||||
if (this.disableSubmit) { return }
|
||||
if (this.emojiInputShown) { return }
|
||||
if (this.submitOnEnter) {
|
||||
|
@ -280,6 +281,8 @@ const PostStatusForm = {
|
|||
event.preventDefault()
|
||||
}
|
||||
|
||||
if (this.optimisticPosting && (this.emptyStatus || this.isOverLengthLimit)) { return }
|
||||
|
||||
if (this.emptyStatus) {
|
||||
this.error = this.$t('post_status.empty_status_error')
|
||||
return
|
||||
|
|
|
@ -124,7 +124,7 @@
|
|||
v-model="newStatus.spoilerText"
|
||||
type="text"
|
||||
:placeholder="$t('post_status.content_warning')"
|
||||
:disabled="posting"
|
||||
:disabled="posting && !optimisticPosting"
|
||||
size="1"
|
||||
class="form-post-subject"
|
||||
>
|
||||
|
@ -150,7 +150,7 @@
|
|||
:placeholder="placeholder || $t('post_status.default')"
|
||||
rows="1"
|
||||
cols="1"
|
||||
:disabled="posting"
|
||||
:disabled="posting && !optimisticPosting"
|
||||
class="form-post-body"
|
||||
:class="{ 'scrollable-form': !!maxHeight }"
|
||||
@keydown.exact.enter="submitOnEnter && postStatus($event, newStatus)"
|
||||
|
|
|
@ -75,12 +75,18 @@ const api = {
|
|||
} else if (message.event === 'delete') {
|
||||
dispatch('deleteStatusById', message.id)
|
||||
} else if (message.event === 'pleroma:chat_update') {
|
||||
dispatch('addChatMessages', {
|
||||
chatId: message.chatUpdate.id,
|
||||
messages: [message.chatUpdate.lastMessage]
|
||||
})
|
||||
dispatch('updateChat', { chat: message.chatUpdate })
|
||||
maybeShowChatNotification(store, message.chatUpdate)
|
||||
// The setTimeout wrapper is a temporary band-aid to avoid duplicates for the user's own messages when doing optimistic sending.
|
||||
// The cause of the duplicates is the WS event arriving earlier than the HTTP response.
|
||||
// This setTimeout wrapper can be removed once the commit `8e41baff` is in the stable Pleroma release.
|
||||
// (`8e41baff` adds the idempotency key to the chat message entity, which PleromaFE uses when it's available, and it makes this artificial delay unnecessary).
|
||||
setTimeout(() => {
|
||||
dispatch('addChatMessages', {
|
||||
chatId: message.chatUpdate.id,
|
||||
messages: [message.chatUpdate.lastMessage]
|
||||
})
|
||||
dispatch('updateChat', { chat: message.chatUpdate })
|
||||
maybeShowChatNotification(store, message.chatUpdate)
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
|
@ -16,7 +16,8 @@ const defaultState = {
|
|||
openedChats: {},
|
||||
openedChatMessageServices: {},
|
||||
fetcher: undefined,
|
||||
currentChatId: null
|
||||
currentChatId: null,
|
||||
lastReadMessageId: null
|
||||
}
|
||||
|
||||
const getChatById = (state, id) => {
|
||||
|
@ -92,9 +93,14 @@ const chats = {
|
|||
commit('setCurrentChatFetcher', { fetcher: undefined })
|
||||
},
|
||||
readChat ({ rootState, commit, dispatch }, { id, lastReadId }) {
|
||||
const isNewMessage = rootState.chats.lastReadMessageId !== lastReadId
|
||||
|
||||
dispatch('resetChatNewMessageCount')
|
||||
commit('readChat', { id })
|
||||
rootState.api.backendInteractor.readChat({ id, lastReadId })
|
||||
commit('readChat', { id, lastReadId })
|
||||
|
||||
if (isNewMessage) {
|
||||
rootState.api.backendInteractor.readChat({ id, lastReadId })
|
||||
}
|
||||
},
|
||||
deleteChatMessage ({ rootState, commit }, value) {
|
||||
rootState.api.backendInteractor.deleteChatMessage(value)
|
||||
|
@ -106,6 +112,9 @@ const chats = {
|
|||
},
|
||||
clearOpenedChats ({ rootState, commit, dispatch, rootGetters }) {
|
||||
commit('clearOpenedChats', { commit })
|
||||
},
|
||||
handleMessageError ({ commit }, value) {
|
||||
commit('handleMessageError', { commit, ...value })
|
||||
}
|
||||
},
|
||||
mutations: {
|
||||
|
@ -208,11 +217,16 @@ const chats = {
|
|||
}
|
||||
}
|
||||
},
|
||||
readChat (state, { id }) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -129,7 +129,11 @@ const promisedRequest = ({ method, url, params, payload, credentials, headers =
|
|||
return reject(new StatusCodeError(response.status, json, { url, options }, response))
|
||||
}
|
||||
return resolve(json)
|
||||
}))
|
||||
})
|
||||
.catch((error) => {
|
||||
return reject(new StatusCodeError(response.status, error, { url, options }, response))
|
||||
})
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -1210,7 +1214,7 @@ const chatMessages = ({ id, credentials, maxId, sinceId, limit = 20 }) => {
|
|||
})
|
||||
}
|
||||
|
||||
const sendChatMessage = ({ id, content, mediaId = null, credentials }) => {
|
||||
const sendChatMessage = ({ id, content, mediaId = null, idempotencyKey, credentials }) => {
|
||||
const payload = {
|
||||
'content': content
|
||||
}
|
||||
|
@ -1219,11 +1223,18 @@ const sendChatMessage = ({ id, content, mediaId = null, credentials }) => {
|
|||
payload['media_id'] = mediaId
|
||||
}
|
||||
|
||||
const headers = {}
|
||||
|
||||
if (idempotencyKey) {
|
||||
headers['idempotency-key'] = idempotencyKey
|
||||
}
|
||||
|
||||
return promisedRequest({
|
||||
url: PLEROMA_CHAT_MESSAGES_URL(id),
|
||||
method: 'POST',
|
||||
payload: payload,
|
||||
credentials
|
||||
credentials,
|
||||
headers
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@ import _ from 'lodash'
|
|||
const empty = (chatId) => {
|
||||
return {
|
||||
idIndex: {},
|
||||
idempotencyKeyIndex: {},
|
||||
messages: [],
|
||||
newMessageCount: 0,
|
||||
lastSeenTimestamp: 0,
|
||||
|
@ -13,8 +14,18 @@ const empty = (chatId) => {
|
|||
}
|
||||
|
||||
const clear = (storage) => {
|
||||
storage.idIndex = {}
|
||||
storage.messages.splice(0, storage.messages.length)
|
||||
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.id]
|
||||
}
|
||||
}
|
||||
|
||||
storage.messages = storage.messages.filter(m => failedMessageIds.includes(m.id))
|
||||
storage.newMessageCount = 0
|
||||
storage.lastSeenTimestamp = 0
|
||||
storage.minId = undefined
|
||||
|
@ -37,6 +48,25 @@ const deleteMessage = (storage, messageId) => {
|
|||
}
|
||||
}
|
||||
|
||||
const handleMessageError = (storage, fakeId, isRetry) => {
|
||||
if (!storage) { return }
|
||||
const fakeMessage = storage.idIndex[fakeId]
|
||||
if (fakeMessage) {
|
||||
fakeMessage.error = true
|
||||
fakeMessage.pending = false
|
||||
if (!isRetry) {
|
||||
// Ensure the failed message doesn't stay at the bottom of the list.
|
||||
const lastPersistedMessage = _.orderBy(storage.messages, ['pending', 'id'], ['asc', 'desc'])[0]
|
||||
if (lastPersistedMessage) {
|
||||
const oldId = fakeMessage.id
|
||||
fakeMessage.id = `${lastPersistedMessage.id}-${new Date().getTime()}`
|
||||
storage.idIndex[fakeMessage.id] = fakeMessage
|
||||
delete storage.idIndex[oldId]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const add = (storage, { messages: newMessages, updateMaxId = true }) => {
|
||||
if (!storage) { return }
|
||||
for (let i = 0; i < newMessages.length; i++) {
|
||||
|
@ -45,7 +75,19 @@ const add = (storage, { messages: newMessages, updateMaxId = true }) => {
|
|||
// sanity check
|
||||
if (message.chat_id !== storage.chatId) { return }
|
||||
|
||||
if (!storage.minId || message.id < storage.minId) {
|
||||
if (message.fakeId) {
|
||||
const fakeMessage = storage.idIndex[message.fakeId]
|
||||
if (fakeMessage) {
|
||||
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
|
||||
}
|
||||
|
||||
|
@ -55,16 +97,22 @@ const add = (storage, { messages: newMessages, updateMaxId = true }) => {
|
|||
}
|
||||
}
|
||||
|
||||
if (!storage.idIndex[message.id]) {
|
||||
if (!storage.idIndex[message.id] && !isConfirmation(storage, message)) {
|
||||
if (storage.lastSeenTimestamp < message.created_at) {
|
||||
storage.newMessageCount++
|
||||
}
|
||||
storage.messages.push(message)
|
||||
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) { return }
|
||||
storage.newMessageCount = 0
|
||||
|
@ -76,7 +124,7 @@ const getView = (storage) => {
|
|||
if (!storage) { return [] }
|
||||
|
||||
const result = []
|
||||
const messages = _.sortBy(storage.messages, ['id', 'desc'])
|
||||
const messages = _.orderBy(storage.messages, ['pending', 'id'], ['asc', 'asc'])
|
||||
const firstMessage = messages[0]
|
||||
let previousMessage = messages[messages.length - 1]
|
||||
let currentMessageChainId
|
||||
|
@ -148,7 +196,8 @@ const ChatService = {
|
|||
getView,
|
||||
deleteMessage,
|
||||
resetNewMessageCount,
|
||||
clear
|
||||
clear,
|
||||
handleMessageError
|
||||
}
|
||||
|
||||
export default ChatService
|
||||
|
|
|
@ -18,3 +18,24 @@ export const maybeShowChatNotification = (store, chat) => {
|
|||
|
||||
showDesktopNotification(store.rootState, opts)
|
||||
}
|
||||
|
||||
export const buildFakeMessage = ({ content, chatId, attachments, userId, idempotencyKey }) => {
|
||||
const fakeMessage = {
|
||||
content,
|
||||
chat_id: chatId,
|
||||
created_at: new Date(),
|
||||
id: `${new Date().getTime()}`,
|
||||
attachments: attachments,
|
||||
account_id: userId,
|
||||
idempotency_key: idempotencyKey,
|
||||
emojis: [],
|
||||
pending: true,
|
||||
isNormalized: true
|
||||
}
|
||||
|
||||
if (attachments[0]) {
|
||||
fakeMessage.attachment = attachments[0]
|
||||
}
|
||||
|
||||
return fakeMessage
|
||||
}
|
||||
|
|
|
@ -429,6 +429,9 @@ export const parseChatMessage = (message) => {
|
|||
} else {
|
||||
output.attachments = []
|
||||
}
|
||||
output.pending = !!message.pending
|
||||
output.error = false
|
||||
output.idempotency_key = message.idempotency_key
|
||||
output.isNormalized = true
|
||||
return output
|
||||
}
|
||||
|
|
|
@ -2,17 +2,20 @@ import chatService from '../../../../../src/services/chat_service/chat_service.j
|
|||
|
||||
const message1 = {
|
||||
id: '9wLkdcmQXD21Oy8lEX',
|
||||
idempotency_key: '1',
|
||||
created_at: (new Date('2020-06-22T18:45:53.000Z'))
|
||||
}
|
||||
|
||||
const message2 = {
|
||||
id: '9wLkdp6ihaOVdNj8Wu',
|
||||
idempotency_key: '2',
|
||||
account_id: '9vmRb29zLQReckr5ay',
|
||||
created_at: (new Date('2020-06-22T18:45:56.000Z'))
|
||||
}
|
||||
|
||||
const message3 = {
|
||||
id: '9wLke9zL4Dy4OZR2RM',
|
||||
idempotency_key: '3',
|
||||
account_id: '9vmRb29zLQReckr5ay',
|
||||
created_at: (new Date('2020-07-22T18:45:59.000Z'))
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue