import _ from 'lodash' import { WSConnectionStatus } from '../../services/api/api.service.js' import { mapGetters, mapState } from 'vuex' import ChatMessage from '../chat_message/chat_message.vue' import PostStatusForm from '../post_status_form/post_status_form.vue' import ChatTitle from '../chat_title/chat_title.vue' import chatService from '../../services/chat_service/chat_service.js' import { promiseInterval } from '../../services/promise_interval/promise_interval.js' import { getScrollPosition, getNewTopPosition, isBottomedOut, scrollableContainerHeight, isScrollable } from './chat_layout_utils.js' import { library } from '@fortawesome/fontawesome-svg-core' import { faChevronDown, faChevronLeft } from '@fortawesome/free-solid-svg-icons' import { buildFakeMessage } from '../../services/chat_utils/chat_utils.js' library.add( faChevronDown, faChevronLeft ) 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: { ChatMessage, ChatTitle, PostStatusForm }, data () { return { jumpToBottomButtonVisible: false, hoveredMessageChainId: undefined, lastScrollPosition: {}, scrollableContainerHeight: '100%', errorLoadingChat: false, messageRetriers: {} } }, created () { this.startFetching() window.addEventListener('resize', this.handleLayoutChange) }, mounted () { window.addEventListener('scroll', this.handleScroll) if (typeof document.hidden !== 'undefined') { document.addEventListener('visibilitychange', this.handleVisibilityChange, false) } this.$nextTick(() => { this.updateScrollableContainerHeight() this.handleResize() }) this.setChatLayout() }, unmounted () { window.removeEventListener('scroll', this.handleScroll) window.removeEventListener('resize', this.handleLayoutChange) this.unsetChatLayout() if (typeof document.hidden !== 'undefined') document.removeEventListener('visibilitychange', this.handleVisibilityChange, false) this.$store.dispatch('clearCurrentChat') }, computed: { recipient () { return this.currentChat && this.currentChat.account }, recipientId () { return this.$route.params.recipient_id }, formPlaceholder () { if (this.recipient) { return this.$t('chats.message_user', { nickname: this.recipient.screen_name_ui }) } else { return '' } }, chatViewItems () { return chatService.getView(this.currentChatMessageService) }, newMessageCount () { return this.currentChatMessageService && this.currentChatMessageService.newMessageCount }, streamingEnabled () { return this.mergedConfig.useStreamingApi && this.mastoUserSocketStatus === WSConnectionStatus.JOINED }, ...mapGetters([ 'currentChat', 'currentChatMessageService', 'findOpenedChatByRecipientId', 'mergedConfig' ]), ...mapState({ backendInteractor: state => state.api.backendInteractor, mastoUserSocketStatus: state => state.api.mastoUserSocketStatus, mobileLayout: state => state.interface.mobileLayout, layoutHeight: state => state.interface.layoutHeight, currentUser: state => state.users.currentUser }) }, watch: { chatViewItems () { // 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() } }) }, '$route': function () { this.startFetching() }, layoutHeight () { this.handleResize({ expand: true }) }, mastoUserSocketStatus (newValue) { if (newValue === WSConnectionStatus.JOINED) { this.fetchChat({ isFirstFetch: true }) } } }, 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() this.updateScrollableContainerHeight() }) }, handleVisibilityChange () { this.$nextTick(() => { if (!document.hidden && this.bottomedOut(BOTTOMED_OUT_OFFSET)) { this.scrollDown({ forceRead: true }) } }) }, setChatLayout () { // This is a hacky way to adjust the global layout to the mobile chat (without modifying the rest of the app). // This layout prevents empty spaces from being visible at the bottom // of the chat on iOS Safari (`safe-area-inset`) when // - the on-screen keyboard appears and the user starts typing // - the user selects the text inside the input area // - the user selects and deletes the text that is multiple lines long // TODO: unify the chat layout with the global layout. let html = document.querySelector('html') if (html) { html.classList.add('chat-layout') } this.$nextTick(() => { this.updateScrollableContainerHeight() }) }, unsetChatLayout () { let html = document.querySelector('html') if (html) { html.classList.remove('chat-layout') } }, handleLayoutChange () { this.$nextTick(() => { this.updateScrollableContainerHeight() this.scrollDown() }) }, // Ensures the proper position of the posting form in the mobile layout (the mobile browser panel does not overlap or hide it) updateScrollableContainerHeight () { const header = this.$refs.header const footer = this.$refs.footer const inner = this.mobileLayout ? window.document.body : this.$refs.inner this.scrollableContainerHeight = scrollableContainerHeight(inner, header, footer) + 'px' }, // Preserves the scroll position when OSK appears or the posting form changes its height. handleResize (opts = {}) { const { expand = false, delayed = false } = opts if (delayed) { setTimeout(() => { this.handleResize({ ...opts, delayed: false }) }, SAFE_RESIZE_TIME_OFFSET) return } this.$nextTick(() => { this.updateScrollableContainerHeight() const { offsetHeight = undefined } = this.lastScrollPosition this.lastScrollPosition = getScrollPosition(this.$refs.scrollable) const diff = this.lastScrollPosition.offsetHeight - offsetHeight if (diff < 0 || (!this.bottomedOut() && expand)) { this.$nextTick(() => { this.updateScrollableContainerHeight() this.$refs.scrollable.scrollTo({ top: this.$refs.scrollable.scrollTop - diff, left: 0 }) }) } }) }, scrollDown (options = {}) { const { behavior = 'auto', forceRead = false } = options const scrollable = this.$refs.scrollable if (!scrollable) { return } this.$nextTick(() => { scrollable.scrollTo({ top: scrollable.scrollHeight, left: 0, behavior }) }) if (forceRead) { this.readChat() } }, readChat () { 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 }) }, bottomedOut (offset) { return isBottomedOut(this.$refs.scrollable, offset) }, reachedTop () { const scrollable = this.$refs.scrollable return scrollable && scrollable.scrollTop <= 0 }, cullOlderCheck () { window.setTimeout(() => { if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) { this.$store.dispatch('cullOlderMessages', this.currentChatMessageService.chatId) } }, 5000) }, handleScroll: _.throttle(function () { if (!this.currentChat) { return } if (this.reachedTop()) { this.fetchChat({ maxId: this.currentChatMessageService.minId }) } else if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) { this.jumpToBottomButtonVisible = false this.cullOlderCheck() 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(() => { // 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) } } else { this.jumpToBottomButtonVisible = true } }, 200), handleScrollUp (positionBeforeLoading) { const positionAfterLoading = getScrollPosition(this.$refs.scrollable) this.$refs.scrollable.scrollTo({ top: getNewTopPosition(positionBeforeLoading, positionAfterLoading), left: 0 }) }, fetchChat ({ isFirstFetch = false, fetchLatest = false, maxId }) { const chatMessageService = this.currentChatMessageService if (!chatMessageService) { return } if (fetchLatest && this.streamingEnabled) { return } const chatId = chatMessageService.chatId const fetchOlderMessages = !!maxId const sinceId = fetchLatest && chatMessageService.maxId return this.backendInteractor.chatMessages({ id: chatId, maxId, sinceId }) .then((messages) => { // Clear the current chat in case we're recovering from a ws connection loss. if (isFirstFetch) { chatService.clear(chatMessageService) } const positionBeforeUpdate = getScrollPosition(this.$refs.scrollable) this.$store.dispatch('addChatMessages', { chatId, messages }).then(() => { this.$nextTick(() => { if (fetchOlderMessages) { this.handleScrollUp(positionBeforeUpdate) } if (isFirstFetch) { this.updateScrollableContainerHeight() } // 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(this.$refs.scrollable) && messages.length > 0) { this.fetchChat({ maxId: this.currentChatMessageService.minId }) } }) }) }) }, async startFetching () { let chat = this.findOpenedChatByRecipientId(this.recipientId) if (!chat) { try { chat = await this.backendInteractor.getOrCreateChat({ accountId: this.recipientId }) } 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() } }, doStartFetching () { this.$store.dispatch('startFetchingCurrentChat', { fetcher: () => promiseInterval(() => this.fetchChat({ fetchLatest: true }), 5000) }) this.fetchChat({ isFirstFetch: true }) }, 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, 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 }) .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) }, goBack () { this.$router.push({ name: 'chats', params: { username: this.currentUser.screen_name } }) } } } export default Chat