Merge branch 'optimistic-chat-posting' into 'develop'

Optimistic / nonblocking message posting for chats

See merge request pleroma/pleroma-fe!1228
This commit is contained in:
Shpuld Shpludson 2020-11-02 05:36:42 +00:00
commit 5254fdba75
13 changed files with 212 additions and 44 deletions

View File

@ -12,6 +12,7 @@ import {
faChevronDown, faChevronDown,
faChevronLeft faChevronLeft
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
import { buildFakeMessage } from '../../services/chat_utils/chat_utils.js'
library.add( library.add(
faChevronDown, faChevronDown,
@ -22,6 +23,7 @@ const BOTTOMED_OUT_OFFSET = 10
const JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET = 150 const JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET = 150
const SAFE_RESIZE_TIME_OFFSET = 100 const SAFE_RESIZE_TIME_OFFSET = 100
const MARK_AS_READ_DELAY = 1500 const MARK_AS_READ_DELAY = 1500
const MAX_RETRIES = 10
const Chat = { const Chat = {
components: { components: {
@ -35,7 +37,8 @@ const Chat = {
hoveredMessageChainId: undefined, hoveredMessageChainId: undefined,
lastScrollPosition: {}, lastScrollPosition: {},
scrollableContainerHeight: '100%', scrollableContainerHeight: '100%',
errorLoadingChat: false errorLoadingChat: false,
messageRetriers: {}
} }
}, },
created () { created () {
@ -219,7 +222,10 @@ const Chat = {
if (!(this.currentChatMessageService && this.currentChatMessageService.maxId)) { return } if (!(this.currentChatMessageService && this.currentChatMessageService.maxId)) { return }
if (document.hidden) { return } if (document.hidden) { return }
const lastReadId = this.currentChatMessageService.maxId 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) { bottomedOut (offset) {
return isBottomedOut(this.$refs.scrollable, offset) return isBottomedOut(this.$refs.scrollable, offset)
@ -309,23 +315,7 @@ const Chat = {
}) })
this.fetchChat({ isFirstFetch: true }) this.fetchChat({ isFirstFetch: true })
}, },
sendMessage ({ status, media }) { handleAttachmentPosting () {
const params = {
id: this.currentChat.id,
content: status
}
if (media[0]) {
params.mediaId = media[0].id
}
return this.backendInteractor.sendChatMessage(params)
.then(data => {
this.$store.dispatch('addChatMessages', {
chatId: this.currentChat.id,
messages: [data],
updateMaxId: false
}).then(() => {
this.$nextTick(() => { this.$nextTick(() => {
this.handleResize() this.handleResize()
// When the posting form size changes because of a media attachment, we need an extra resize // When the posting form size changes because of a media attachment, we need an extra resize
@ -335,16 +325,64 @@ const Chat = {
}, SAFE_RESIZE_TIME_OFFSET) }, SAFE_RESIZE_TIME_OFFSET)
this.scrollDown({ forceRead: true }) this.scrollDown({ forceRead: true })
}) })
},
sendMessage ({ status, media, idempotencyKey }) {
const params = {
id: this.currentChat.id,
content: status,
idempotencyKey
}
if (media[0]) {
params.mediaId = media[0].id
}
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,
updateMaxId: false,
messages: [{ ...data, fakeId: fakeMessage.id }]
}) })
return data return data
}) })
.catch(error => { .catch(error => {
console.error('Error sending message', error) console.error('Error sending message', error)
return { this.$store.dispatch('handleMessageError', {
error: this.$t('chats.error_sending_message') 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 () { goBack () {
this.$router.push({ name: 'chats', params: { username: this.currentUser.screen_name } }) this.$router.push({ name: 'chats', params: { username: this.currentUser.screen_name } })

View File

@ -80,6 +80,7 @@
:disable-sensitivity-checkbox="true" :disable-sensitivity-checkbox="true"
:disable-submit="errorLoadingChat || !currentChat" :disable-submit="errorLoadingChat || !currentChat"
:disable-preview="true" :disable-preview="true"
:optimistic-posting="true"
:post-handler="sendMessage" :post-handler="sendMessage"
:submit-on-enter="!mobileLayout" :submit-on-enter="!mobileLayout"
:preserve-focus="!mobileLayout" :preserve-focus="!mobileLayout"

View File

@ -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 { .incoming {
a { a {
color: var(--chatMessageIncomingLink, $fallback--link); color: var(--chatMessageIncomingLink, $fallback--link);

View File

@ -32,7 +32,7 @@
> >
<div <div
class="media status" class="media status"
:class="{ 'without-attachment': !hasAttachment }" :class="{ 'without-attachment': !hasAttachment, 'pending': chatViewItem.data.pending, 'error': chatViewItem.data.error }"
style="position: relative" style="position: relative"
@mouseenter="hovered = true" @mouseenter="hovered = true"
@mouseleave="hovered = false" @mouseleave="hovered = false"

View File

@ -75,7 +75,8 @@ const PostStatusForm = {
'autoFocus', 'autoFocus',
'fileLimit', 'fileLimit',
'submitOnEnter', 'submitOnEnter',
'emojiPickerPlacement' 'emojiPickerPlacement',
'optimisticPosting'
], ],
components: { components: {
MediaUpload, MediaUpload,
@ -272,7 +273,7 @@ const PostStatusForm = {
if (this.preview) this.previewStatus() if (this.preview) this.previewStatus()
}, },
async postStatus (event, newStatus, opts = {}) { async postStatus (event, newStatus, opts = {}) {
if (this.posting) { return } if (this.posting && !this.optimisticPosting) { return }
if (this.disableSubmit) { return } if (this.disableSubmit) { return }
if (this.emojiInputShown) { return } if (this.emojiInputShown) { return }
if (this.submitOnEnter) { if (this.submitOnEnter) {
@ -280,6 +281,8 @@ const PostStatusForm = {
event.preventDefault() event.preventDefault()
} }
if (this.optimisticPosting && (this.emptyStatus || this.isOverLengthLimit)) { return }
if (this.emptyStatus) { if (this.emptyStatus) {
this.error = this.$t('post_status.empty_status_error') this.error = this.$t('post_status.empty_status_error')
return return

View File

@ -129,7 +129,7 @@
v-model="newStatus.spoilerText" v-model="newStatus.spoilerText"
type="text" type="text"
:placeholder="$t('post_status.content_warning')" :placeholder="$t('post_status.content_warning')"
:disabled="posting" :disabled="posting && !optimisticPosting"
size="1" size="1"
class="form-post-subject" class="form-post-subject"
> >
@ -155,7 +155,7 @@
:placeholder="placeholder || $t('post_status.default')" :placeholder="placeholder || $t('post_status.default')"
rows="1" rows="1"
cols="1" cols="1"
:disabled="posting" :disabled="posting && !optimisticPosting"
class="form-post-body" class="form-post-body"
:class="{ 'scrollable-form': !!maxHeight }" :class="{ 'scrollable-form': !!maxHeight }"
@keydown.exact.enter="submitOnEnter && postStatus($event, newStatus)" @keydown.exact.enter="submitOnEnter && postStatus($event, newStatus)"

View File

@ -75,12 +75,18 @@ const api = {
} else if (message.event === 'delete') { } else if (message.event === 'delete') {
dispatch('deleteStatusById', message.id) dispatch('deleteStatusById', message.id)
} else if (message.event === 'pleroma:chat_update') { } else if (message.event === 'pleroma:chat_update') {
// 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', { dispatch('addChatMessages', {
chatId: message.chatUpdate.id, chatId: message.chatUpdate.id,
messages: [message.chatUpdate.lastMessage] messages: [message.chatUpdate.lastMessage]
}) })
dispatch('updateChat', { chat: message.chatUpdate }) dispatch('updateChat', { chat: message.chatUpdate })
maybeShowChatNotification(store, message.chatUpdate) maybeShowChatNotification(store, message.chatUpdate)
}, 100)
} }
} }
) )

View File

@ -16,7 +16,8 @@ const defaultState = {
openedChats: {}, openedChats: {},
openedChatMessageServices: {}, openedChatMessageServices: {},
fetcher: undefined, fetcher: undefined,
currentChatId: null currentChatId: null,
lastReadMessageId: null
} }
const getChatById = (state, id) => { const getChatById = (state, id) => {
@ -92,9 +93,14 @@ const chats = {
commit('setCurrentChatFetcher', { fetcher: undefined }) commit('setCurrentChatFetcher', { fetcher: undefined })
}, },
readChat ({ rootState, commit, dispatch }, { id, lastReadId }) { readChat ({ rootState, commit, dispatch }, { id, lastReadId }) {
const isNewMessage = rootState.chats.lastReadMessageId !== lastReadId
dispatch('resetChatNewMessageCount') dispatch('resetChatNewMessageCount')
commit('readChat', { id }) commit('readChat', { id, lastReadId })
if (isNewMessage) {
rootState.api.backendInteractor.readChat({ id, lastReadId }) rootState.api.backendInteractor.readChat({ id, lastReadId })
}
}, },
deleteChatMessage ({ rootState, commit }, value) { deleteChatMessage ({ rootState, commit }, value) {
rootState.api.backendInteractor.deleteChatMessage(value) rootState.api.backendInteractor.deleteChatMessage(value)
@ -106,6 +112,9 @@ const chats = {
}, },
clearOpenedChats ({ rootState, commit, dispatch, rootGetters }) { clearOpenedChats ({ rootState, commit, dispatch, rootGetters }) {
commit('clearOpenedChats', { commit }) commit('clearOpenedChats', { commit })
},
handleMessageError ({ commit }, value) {
commit('handleMessageError', { commit, ...value })
} }
}, },
mutations: { mutations: {
@ -208,11 +217,16 @@ const chats = {
} }
} }
}, },
readChat (state, { id }) { 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)
} }
} }
} }

View File

@ -129,7 +129,11 @@ const promisedRequest = ({ method, url, params, payload, credentials, headers =
return reject(new StatusCodeError(response.status, json, { url, options }, response)) return reject(new StatusCodeError(response.status, json, { url, options }, response))
} }
return resolve(json) 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 = { const payload = {
'content': content 'content': content
} }
@ -1219,11 +1223,18 @@ const sendChatMessage = ({ id, content, mediaId = null, credentials }) => {
payload['media_id'] = mediaId payload['media_id'] = mediaId
} }
const headers = {}
if (idempotencyKey) {
headers['idempotency-key'] = idempotencyKey
}
return promisedRequest({ return promisedRequest({
url: PLEROMA_CHAT_MESSAGES_URL(id), url: PLEROMA_CHAT_MESSAGES_URL(id),
method: 'POST', method: 'POST',
payload: payload, payload: payload,
credentials credentials,
headers
}) })
} }

View File

@ -3,6 +3,7 @@ import _ from 'lodash'
const empty = (chatId) => { const empty = (chatId) => {
return { return {
idIndex: {}, idIndex: {},
idempotencyKeyIndex: {},
messages: [], messages: [],
newMessageCount: 0, newMessageCount: 0,
lastSeenTimestamp: 0, lastSeenTimestamp: 0,
@ -13,8 +14,18 @@ const empty = (chatId) => {
} }
const clear = (storage) => { const clear = (storage) => {
storage.idIndex = {} const failedMessageIds = []
storage.messages.splice(0, storage.messages.length)
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.newMessageCount = 0
storage.lastSeenTimestamp = 0 storage.lastSeenTimestamp = 0
storage.minId = undefined 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 }) => { const add = (storage, { messages: newMessages, updateMaxId = true }) => {
if (!storage) { return } if (!storage) { return }
for (let i = 0; i < newMessages.length; i++) { for (let i = 0; i < newMessages.length; i++) {
@ -45,7 +75,25 @@ const add = (storage, { messages: newMessages, updateMaxId = true }) => {
// sanity check // sanity check
if (message.chat_id !== storage.chatId) { return } 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) {
// 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 storage.minId = message.id
} }
@ -55,16 +103,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) { if (storage.lastSeenTimestamp < message.created_at) {
storage.newMessageCount++ storage.newMessageCount++
} }
storage.messages.push(message)
storage.idIndex[message.id] = 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) => { const resetNewMessageCount = (storage) => {
if (!storage) { return } if (!storage) { return }
storage.newMessageCount = 0 storage.newMessageCount = 0
@ -76,7 +130,7 @@ const getView = (storage) => {
if (!storage) { return [] } if (!storage) { return [] }
const result = [] const result = []
const messages = _.sortBy(storage.messages, ['id', 'desc']) const messages = _.orderBy(storage.messages, ['pending', 'id'], ['asc', 'asc'])
const firstMessage = messages[0] const firstMessage = messages[0]
let previousMessage = messages[messages.length - 1] let previousMessage = messages[messages.length - 1]
let currentMessageChainId let currentMessageChainId
@ -148,7 +202,8 @@ const ChatService = {
getView, getView,
deleteMessage, deleteMessage,
resetNewMessageCount, resetNewMessageCount,
clear clear,
handleMessageError
} }
export default ChatService export default ChatService

View File

@ -18,3 +18,24 @@ export const maybeShowChatNotification = (store, chat) => {
showDesktopNotification(store.rootState, opts) 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
}

View File

@ -429,6 +429,9 @@ export const parseChatMessage = (message) => {
} else { } else {
output.attachments = [] output.attachments = []
} }
output.pending = !!message.pending
output.error = false
output.idempotency_key = message.idempotency_key
output.isNormalized = true output.isNormalized = true
return output return output
} }

View File

@ -2,17 +2,20 @@ import chatService from '../../../../../src/services/chat_service/chat_service.j
const message1 = { const message1 = {
id: '9wLkdcmQXD21Oy8lEX', id: '9wLkdcmQXD21Oy8lEX',
idempotency_key: '1',
created_at: (new Date('2020-06-22T18:45:53.000Z')) created_at: (new Date('2020-06-22T18:45:53.000Z'))
} }
const message2 = { const message2 = {
id: '9wLkdp6ihaOVdNj8Wu', id: '9wLkdp6ihaOVdNj8Wu',
idempotency_key: '2',
account_id: '9vmRb29zLQReckr5ay', account_id: '9vmRb29zLQReckr5ay',
created_at: (new Date('2020-06-22T18:45:56.000Z')) created_at: (new Date('2020-06-22T18:45:56.000Z'))
} }
const message3 = { const message3 = {
id: '9wLke9zL4Dy4OZR2RM', id: '9wLke9zL4Dy4OZR2RM',
idempotency_key: '3',
account_id: '9vmRb29zLQReckr5ay', account_id: '9vmRb29zLQReckr5ay',
created_at: (new Date('2020-07-22T18:45:59.000Z')) created_at: (new Date('2020-07-22T18:45:59.000Z'))
} }