pleroma-fe/src/components/chat/chat.js

418 lines
12 KiB
JavaScript
Raw Normal View History

2020-05-07 16:10:53 +03:00
import _ from 'lodash'
2023-04-05 21:06:37 -06:00
import { mapState as mapPiniaState } from 'pinia'
2026-01-06 16:23:17 +02:00
import { mapGetters, mapState } from 'vuex'
2026-01-08 17:26:52 +02:00
import { useInterfaceStore } from 'src/stores/interface.js'
2026-01-06 16:23:17 +02:00
import { WSConnectionStatus } from '../../services/api/api.service.js'
2020-05-07 16:10:53 +03:00
import chatService from '../../services/chat_service/chat_service.js'
2026-01-06 16:23:17 +02:00
import { buildFakeMessage } from '../../services/chat_utils/chat_utils.js'
2020-09-04 11:19:53 +03:00
import { promiseInterval } from '../../services/promise_interval/promise_interval.js'
2026-01-06 16:23:17 +02:00
import ChatMessage from '../chat_message/chat_message.vue'
import ChatTitle from '../chat_title/chat_title.vue'
import PostStatusForm from '../post_status_form/post_status_form.vue'
2020-10-20 21:03:46 +03:00
import {
2026-01-06 16:22:52 +02:00
getNewTopPosition,
2026-01-06 16:23:17 +02:00
getScrollPosition,
2026-01-06 16:22:52 +02:00
isBottomedOut,
isScrollable,
} from './chat_layout_utils.js'
2020-10-20 21:03:46 +03:00
2026-01-08 17:26:52 +02:00
import { library } from '@fortawesome/fontawesome-svg-core'
import { faChevronDown, faChevronLeft } from '@fortawesome/free-solid-svg-icons'
2026-01-06 16:22:52 +02:00
library.add(faChevronDown, faChevronLeft)
2020-05-07 16:10:53 +03:00
const BOTTOMED_OUT_OFFSET = 10
const JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET = 10
const SAFE_RESIZE_TIME_OFFSET = 100
const MARK_AS_READ_DELAY = 1500
2020-10-29 13:33:06 +03:00
const MAX_RETRIES = 10
2020-05-07 16:10:53 +03:00
const Chat = {
components: {
ChatMessage,
ChatTitle,
2026-01-06 16:22:52 +02:00
PostStatusForm,
2020-05-07 16:10:53 +03:00
},
2026-01-06 16:22:52 +02:00
data() {
2020-05-07 16:10:53 +03:00
return {
jumpToBottomButtonVisible: false,
hoveredMessageChainId: undefined,
lastScrollPosition: {},
2020-05-07 16:10:53 +03:00
scrollableContainerHeight: '100%',
2020-10-29 13:33:06 +03:00
errorLoadingChat: false,
2026-01-06 16:22:52 +02:00
messageRetriers: {},
2020-05-07 16:10:53 +03:00
}
},
2026-01-06 16:22:52 +02:00
created() {
2020-05-07 16:10:53 +03:00
this.startFetching()
window.addEventListener('resize', this.handleResize)
2020-05-07 16:10:53 +03:00
},
2026-01-06 16:22:52 +02:00
mounted() {
2022-04-10 19:28:26 +03:00
window.addEventListener('scroll', this.handleScroll)
2020-05-07 16:10:53 +03:00
if (typeof document.hidden !== 'undefined') {
2026-01-06 16:22:52 +02:00
document.addEventListener(
'visibilitychange',
this.handleVisibilityChange,
false,
)
2020-05-07 16:10:53 +03:00
}
this.$nextTick(() => {
this.handleResize()
})
},
2026-01-06 16:22:52 +02:00
unmounted() {
2022-04-10 19:28:26 +03:00
window.removeEventListener('scroll', this.handleScroll)
2022-08-09 01:44:44 +03:00
window.removeEventListener('resize', this.handleResize)
2026-01-06 16:22:52 +02:00
if (typeof document.hidden !== 'undefined')
document.removeEventListener(
'visibilitychange',
this.handleVisibilityChange,
false,
)
2020-05-07 16:10:53 +03:00
this.$store.dispatch('clearCurrentChat')
},
computed: {
2026-01-06 16:22:52 +02:00
recipient() {
2020-05-07 16:10:53 +03:00
return this.currentChat && this.currentChat.account
},
2026-01-06 16:22:52 +02:00
recipientId() {
2020-05-07 16:10:53 +03:00
return this.$route.params.recipient_id
},
2026-01-06 16:22:52 +02:00
formPlaceholder() {
2020-05-07 16:10:53 +03:00
if (this.recipient) {
2026-01-06 16:22:52 +02:00
return this.$t('chats.message_user', {
nickname: this.recipient.screen_name_ui,
})
2020-05-07 16:10:53 +03:00
} else {
return ''
}
},
2026-01-06 16:22:52 +02:00
chatViewItems() {
2020-05-07 16:10:53 +03:00
return chatService.getView(this.currentChatMessageService)
},
2026-01-06 16:22:52 +02:00
newMessageCount() {
return (
this.currentChatMessageService &&
this.currentChatMessageService.newMessageCount
)
2020-05-07 16:10:53 +03:00
},
2026-01-06 16:22:52 +02:00
streamingEnabled() {
return (
this.mergedConfig.useStreamingApi &&
this.mastoUserSocketStatus === WSConnectionStatus.JOINED
)
2020-05-07 16:10:53 +03:00
},
...mapGetters([
'currentChat',
'currentChatMessageService',
'findOpenedChatByRecipientId',
2026-01-06 16:22:52 +02:00
'mergedConfig',
2020-05-07 16:10:53 +03:00
]),
2023-04-05 21:06:37 -06:00
...mapPiniaState(useInterfaceStore, {
2026-01-06 16:22:52 +02:00
mobileLayout: (store) => store.layoutType === 'mobile',
2023-04-05 21:06:37 -06:00
}),
2020-05-07 16:10:53 +03:00
...mapState({
2026-01-06 16:22:52 +02:00
backendInteractor: (state) => state.api.backendInteractor,
mastoUserSocketStatus: (state) => state.api.mastoUserSocketStatus,
currentUser: (state) => state.users.currentUser,
}),
2020-05-07 16:10:53 +03:00
},
watch: {
2026-01-06 16:22:52 +02:00
chatViewItems() {
2020-05-07 16:10:53 +03:00
// 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)
this.$nextTick(() => {
if (bottomedOutBeforeUpdate) {
this.scrollDown()
2020-05-07 16:10:53 +03:00
}
})
},
2022-07-31 12:35:48 +03:00
$route: function () {
2020-05-07 16:10:53 +03:00
this.startFetching()
},
2026-01-06 16:22:52 +02:00
mastoUserSocketStatus(newValue) {
2020-05-07 16:10:53 +03:00
if (newValue === WSConnectionStatus.JOINED) {
this.fetchChat({ isFirstFetch: true })
}
2026-01-06 16:22:52 +02:00
},
2020-05-07 16:10:53 +03:00
},
methods: {
// Used to animate the avatar near the first message of the message chain when any message belonging to the chain is hovered
2026-01-06 16:22:52 +02:00
onMessageHover({ isHovered, messageChainId }) {
2020-05-07 16:10:53 +03:00
this.hoveredMessageChainId = isHovered ? messageChainId : undefined
},
2026-01-06 16:22:52 +02:00
onFilesDropped() {
2020-05-07 16:10:53 +03:00
this.$nextTick(() => {
this.handleResize()
2020-05-07 16:10:53 +03:00
})
},
2026-01-06 16:22:52 +02:00
handleVisibilityChange() {
2020-05-07 16:10:53 +03:00
this.$nextTick(() => {
if (!document.hidden && this.bottomedOut(BOTTOMED_OUT_OFFSET)) {
this.scrollDown({ forceRead: true })
}
})
},
// "Sticks" scroll to bottom instead of top, helps with OSK resizing the viewport
2026-01-06 16:22:52 +02:00
handleResize(opts = {}) {
2022-08-09 01:44:44 +03:00
const { delayed = false } = opts
if (delayed) {
setTimeout(() => {
this.handleResize({ ...opts, delayed: false })
}, SAFE_RESIZE_TIME_OFFSET)
return
}
2020-05-07 16:10:53 +03:00
this.$nextTick(() => {
const { offsetHeight = undefined } = getScrollPosition()
2022-08-09 01:44:44 +03:00
const diff = offsetHeight - this.lastScrollPosition.offsetHeight
if (diff !== 0 && !this.bottomedOut()) {
2020-05-07 16:10:53 +03:00
this.$nextTick(() => {
2022-08-09 01:44:44 +03:00
window.scrollBy({ top: -Math.trunc(diff) })
2020-05-07 16:10:53 +03:00
})
}
this.lastScrollPosition = getScrollPosition()
2020-05-07 16:10:53 +03:00
})
},
2026-01-06 16:22:52 +02:00
scrollDown(options = {}) {
2020-05-07 16:10:53 +03:00
const { behavior = 'auto', forceRead = false } = options
this.$nextTick(() => {
2026-01-06 16:22:52 +02:00
window.scrollTo({
top: document.documentElement.scrollHeight,
behavior,
})
2020-05-07 16:10:53 +03:00
})
if (forceRead) {
2020-05-07 16:10:53 +03:00
this.readChat()
}
},
2026-01-06 16:22:52 +02:00
readChat() {
if (
!(
this.currentChatMessageService && this.currentChatMessageService.maxId
)
) {
return
}
if (document.hidden) {
return
}
const lastReadId = this.currentChatMessageService.maxId
2020-10-29 13:33:06 +03:00
this.$store.dispatch('readChat', {
id: this.currentChat.id,
2026-01-06 16:22:52 +02:00
lastReadId,
2020-10-29 13:33:06 +03:00
})
2020-05-07 16:10:53 +03:00
},
2026-01-06 16:22:52 +02:00
bottomedOut(offset) {
2022-04-10 19:28:26 +03:00
return isBottomedOut(offset)
2020-05-07 16:10:53 +03:00
},
2026-01-06 16:22:52 +02:00
reachedTop() {
2022-04-10 19:28:26 +03:00
return window.scrollY <= 0
2020-05-07 16:10:53 +03:00
},
2026-01-06 16:22:52 +02:00
cullOlderCheck() {
2021-02-18 10:14:45 +02:00
window.setTimeout(() => {
if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) {
2026-01-06 16:22:52 +02:00
this.$store.dispatch(
'cullOlderMessages',
this.currentChatMessageService.chatId,
)
2021-02-18 10:14:45 +02:00
}
}, 5000)
},
2020-05-07 16:10:53 +03:00
handleScroll: _.throttle(function () {
2022-08-09 01:44:44 +03:00
this.lastScrollPosition = getScrollPosition()
2026-01-06 16:22:52 +02:00
if (!this.currentChat) {
return
}
2020-05-07 16:10:53 +03:00
if (this.reachedTop()) {
this.fetchChat({ maxId: this.currentChatMessageService.minId })
} else if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) {
this.jumpToBottomButtonVisible = false
2021-02-18 10:14:45 +02:00
this.cullOlderCheck()
2020-05-07 16:10:53 +03:00
if (this.newMessageCount > 0) {
// Use a delay before marking as read to prevent situation where new messages
// arrive just as you're leaving the view and messages that you didn't actually
// get to see get marked as read.
window.setTimeout(() => {
2020-10-29 12:45:44 +02:00
// Don't mark as read if the element doesn't exist, user has left chat view
if (this.$el) this.readChat()
}, MARK_AS_READ_DELAY)
2020-05-07 16:10:53 +03:00
}
} else {
this.jumpToBottomButtonVisible = true
}
}, 200),
2026-01-06 16:22:52 +02:00
handleScrollUp(positionBeforeLoading) {
2022-04-10 19:28:26 +03:00
const positionAfterLoading = getScrollPosition()
window.scrollTo({
2026-01-06 16:22:52 +02:00
top: getNewTopPosition(positionBeforeLoading, positionAfterLoading),
2020-05-07 16:10:53 +03:00
})
},
2026-01-06 16:22:52 +02:00
fetchChat({ isFirstFetch = false, fetchLatest = false, maxId }) {
2020-05-07 16:10:53 +03:00
const chatMessageService = this.currentChatMessageService
2026-01-06 16:22:52 +02:00
if (!chatMessageService) {
return
}
if (fetchLatest && this.streamingEnabled) {
return
}
2020-05-07 16:10:53 +03:00
const chatId = chatMessageService.chatId
const fetchOlderMessages = !!maxId
const sinceId = fetchLatest && chatMessageService.maxId
2020-05-07 16:10:53 +03:00
2026-01-06 16:22:52 +02:00
return this.backendInteractor
.chatMessages({ id: chatId, maxId, sinceId })
2020-05-07 16:10:53 +03:00
.then((messages) => {
// Clear the current chat in case we're recovering from a ws connection loss.
if (isFirstFetch) {
chatService.clear(chatMessageService)
}
2022-04-10 19:28:26 +03:00
const positionBeforeUpdate = getScrollPosition()
2026-01-06 16:22:52 +02:00
this.$store
.dispatch('addChatMessages', { chatId, messages })
.then(() => {
this.$nextTick(() => {
if (fetchOlderMessages) {
this.handleScrollUp(positionBeforeUpdate)
}
2020-05-07 16:10:53 +03:00
2026-01-06 16:22:52 +02:00
// 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,
})
}
})
2020-05-07 16:10:53 +03:00
})
})
},
2026-01-06 16:22:52 +02:00
async startFetching() {
2020-05-07 16:10:53 +03:00
let chat = this.findOpenedChatByRecipientId(this.recipientId)
if (!chat) {
try {
2026-01-06 16:22:52 +02:00
chat = await this.backendInteractor.getOrCreateChat({
accountId: this.recipientId,
})
2020-05-07 16:10:53 +03:00
} catch (e) {
console.error('Error creating or getting a chat', e)
this.errorLoadingChat = true
}
}
if (chat) {
this.$nextTick(() => {
this.scrollDown({ forceRead: true })
})
this.$store.dispatch('addOpenedChat', { chat })
this.doStartFetching()
}
},
2026-01-06 16:22:52 +02:00
doStartFetching() {
2020-05-07 16:10:53 +03:00
this.$store.dispatch('startFetchingCurrentChat', {
2026-01-06 16:22:52 +02:00
fetcher: () =>
promiseInterval(() => this.fetchChat({ fetchLatest: true }), 5000),
2020-05-07 16:10:53 +03:00
})
this.fetchChat({ isFirstFetch: true })
},
2026-01-06 16:22:52 +02:00
handleAttachmentPosting() {
2020-10-29 13:33:06 +03:00
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.
this.scrollDown({ forceRead: true })
})
},
2026-01-06 16:22:52 +02:00
sendMessage({ status, media, idempotencyKey }) {
2020-05-07 16:10:53 +03:00
const params = {
id: this.currentChat.id,
2020-10-29 13:33:06 +03:00
content: status,
2026-01-06 16:22:52 +02:00
idempotencyKey,
2020-05-07 16:10:53 +03:00
}
if (media[0]) {
params.mediaId = media[0].id
}
2020-10-29 13:33:06 +03:00
const fakeMessage = buildFakeMessage({
attachments: media,
chatId: this.currentChat.id,
content: status,
userId: this.currentUser.id,
2026-01-06 16:22:52 +02:00
idempotencyKey,
2020-10-29 13:33:06 +03:00
})
2026-01-06 16:22:52 +02:00
this.$store
.dispatch('addChatMessages', {
chatId: this.currentChat.id,
messages: [fakeMessage],
})
.then(() => {
this.handleAttachmentPosting()
})
2020-10-29 13:33:06 +03:00
2026-01-06 16:22:52 +02:00
return this.doSendMessage({
params,
fakeMessage,
retriesLeft: MAX_RETRIES,
})
2020-10-29 13:33:06 +03:00
},
2026-01-06 16:22:52 +02:00
doSendMessage({ params, fakeMessage, retriesLeft = MAX_RETRIES }) {
2020-10-29 13:33:06 +03:00
if (retriesLeft <= 0) return
2026-01-06 16:22:52 +02:00
this.backendInteractor
.sendChatMessage(params)
.then((data) => {
this.$store.dispatch('addChatMessages', {
chatId: this.currentChat.id,
2020-10-29 13:33:06 +03:00
updateMaxId: false,
2026-01-06 16:22:52 +02:00
messages: [{ ...data, fakeId: fakeMessage.id }],
2020-05-07 16:10:53 +03:00
})
return data
})
2026-01-06 16:22:52 +02:00
.catch((error) => {
2020-05-07 16:10:53 +03:00
console.error('Error sending message', error)
2020-10-29 13:33:06 +03:00
this.$store.dispatch('handleMessageError', {
chatId: this.currentChat.id,
fakeId: fakeMessage.id,
2026-01-06 16:22:52 +02:00
isRetry: retriesLeft !== MAX_RETRIES,
2020-10-29 13:33:06 +03:00
})
2026-01-06 16:22:52 +02:00
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),
)
2020-05-07 16:10:53 +03:00
}
2020-10-29 13:33:06 +03:00
return {}
2020-05-07 16:10:53 +03:00
})
2020-10-29 13:33:06 +03:00
return Promise.resolve(fakeMessage)
2020-05-07 16:10:53 +03:00
},
2026-01-06 16:22:52 +02:00
goBack() {
this.$router.push({
name: 'chats',
params: { username: this.currentUser.screen_name },
})
},
},
2020-05-07 16:10:53 +03:00
}
export default Chat