aboutsummaryrefslogtreecommitdiff
path: root/src/components/chat
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/chat')
-rw-r--r--src/components/chat/chat.js304
-rw-r--r--src/components/chat/chat.scss161
-rw-r--r--src/components/chat/chat.vue99
-rw-r--r--src/components/chat/chat_layout.js100
-rw-r--r--src/components/chat/chat_layout_utils.js27
5 files changed, 691 insertions, 0 deletions
diff --git a/src/components/chat/chat.js b/src/components/chat/chat.js
new file mode 100644
index 00000000..6e23c20c
--- /dev/null
+++ b/src/components/chat/chat.js
@@ -0,0 +1,304 @@
+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 ChatAvatar from '../chat_avatar/chat_avatar.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 ChatLayout from './chat_layout.js'
+import { getScrollPosition, getNewTopPosition, isBottomedOut, scrollableContainerHeight } from './chat_layout_utils.js'
+
+const BOTTOMED_OUT_OFFSET = 10
+const JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET = 150
+
+const Chat = {
+ components: {
+ ChatMessage,
+ ChatTitle,
+ ChatAvatar,
+ PostStatusForm
+ },
+ mixins: [ChatLayout],
+ data () {
+ return {
+ jumpToBottomButtonVisible: false,
+ hoveredMessageChainId: undefined,
+ scrollPositionBeforeResize: {},
+ scrollableContainerHeight: '100%',
+ errorLoadingChat: false
+ }
+ },
+ 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()
+ },
+ destroyed () {
+ 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 })
+ } 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({ forceRead: !document.hidden })
+ }
+ })
+ },
+ '$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.updateScrollableContainerHeight()
+ })
+ },
+ handleVisibilityChange () {
+ this.$nextTick(() => {
+ if (!document.hidden && this.bottomedOut(BOTTOMED_OUT_OFFSET)) {
+ this.scrollDown({ forceRead: true })
+ }
+ })
+ },
+ handleLayoutChange () {
+ this.updateScrollableContainerHeight()
+ if (this.mobileLayout) {
+ this.setMobileChatLayout()
+ } else {
+ this.unsetMobileChatLayout()
+ }
+ 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) {
+ this.$nextTick(() => {
+ this.updateScrollableContainerHeight()
+
+ const { offsetHeight = undefined } = this.scrollPositionBeforeResize
+ this.scrollPositionBeforeResize = getScrollPosition(this.$refs.scrollable)
+
+ const diff = this.scrollPositionBeforeResize.offsetHeight - offsetHeight
+ if (diff < 0 || (!this.bottomedOut() && opts && opts.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.newMessageCount > 0) {
+ this.readChat()
+ }
+ },
+ readChat () {
+ if (!(this.currentChatMessageService && this.currentChatMessageService.lastMessage)) { return }
+ if (document.hidden) { return }
+ const lastReadId = this.currentChatMessageService.lastMessage.id
+ 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
+ },
+ 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
+ if (this.newMessageCount > 0) {
+ this.readChat()
+ }
+ } else {
+ this.jumpToBottomButtonVisible = true
+ }
+ }, 100),
+ 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.lastMessage && chatMessageService.lastMessage.id
+
+ 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()
+ }
+ })
+ })
+ })
+ },
+ 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: () => setInterval(() => this.fetchChat({ fetchLatest: true }), 5000)
+ })
+ this.fetchChat({ isFirstFetch: true })
+ },
+ sendMessage ({ status, media }) {
+ 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] }).then(() => {
+ this.$nextTick(() => {
+ this.updateScrollableContainerHeight()
+ this.scrollDown({ forceRead: true })
+ })
+ })
+
+ return data
+ })
+ .catch(error => {
+ console.error('Error sending message', error)
+ return {
+ error: this.$t('chats.error_sending_message')
+ }
+ })
+ },
+ goBack () {
+ this.$router.push({ name: 'chats', params: { username: this.currentUser.screen_name } })
+ }
+ }
+}
+
+export default Chat
diff --git a/src/components/chat/chat.scss b/src/components/chat/chat.scss
new file mode 100644
index 00000000..13c52ea3
--- /dev/null
+++ b/src/components/chat/chat.scss
@@ -0,0 +1,161 @@
+.chat-view {
+ display: flex;
+ height: calc(100vh - 60px);
+ width: 100%;
+
+ .chat-view-inner {
+ height: auto;
+ width: 100%;
+ overflow: visible;
+ display: flex;
+ margin-top: 0.5em;
+ margin-left: 0.5em;
+ margin-right: 0.5em;
+ }
+
+ .chat-view-body {
+ background-color: var(--chatBg, $fallback--bg);
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ overflow: visible;
+ border-radius: none;
+ min-height: 100%;
+ margin-left: 0;
+ margin-right: 0;
+ margin-bottom: 0em;
+ margin-top: 0em;
+ border-radius: 10px 10px 0 0;
+ border-radius: var(--panelRadius, 10px) var(--panelRadius, 10px) 0 0 ;
+
+ &::after {
+ border-radius: none;
+ box-shadow: none;
+ }
+ }
+
+ .scrollable-message-list {
+ padding: 0 10px;
+ height: 100%;
+ overflow-y: scroll;
+ overflow-x: hidden;
+ display: flex;
+ flex-direction: column;
+ }
+
+ .footer {
+ position: sticky;
+ bottom: 0px;
+ }
+
+ .chat-view-heading {
+ align-items: center;
+ justify-content: space-between;
+ top: 50px;
+ display: flex;
+ z-index: 2;
+ border-radius: none;
+ position: sticky;
+ display: flex;
+ overflow: hidden;
+ }
+
+ .go-back-button {
+ margin-right: 1.2em;
+ cursor: pointer;
+ }
+
+ .jump-to-bottom-button {
+ width: 2.5em;
+ height: 2.5em;
+ border-radius: 100%;
+ position: absolute;
+ right: 1.3em;
+ top: -3.2em;
+ background-color: $fallback--fg;
+ background-color: var(--btn, $fallback--fg);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ box-shadow: 0px 1px 1px rgba(0, 0, 0, 0.3), 0px 2px 4px rgba(0, 0, 0, 0.3);
+ z-index: 10;
+ transition: 0.35s all;
+ transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
+ opacity: 0;
+ visibility: hidden;
+ cursor: pointer;
+
+ &.visible {
+ opacity: 1;
+ visibility: visible;
+ }
+
+ i {
+ font-size: 1em;
+ color: $fallback--text;
+ color: var(--text, $fallback--text);
+ }
+
+ .unread-message-count {
+ font-size: 0.8em;
+ left: 50%;
+ transform: translate(-50%, 0);
+ border-radius: 100%;
+ margin-top: -1rem;
+ padding: 0;
+ }
+
+ .chat-loading-error {
+ width: 100%;
+ display: flex;
+ align-items: flex-end;
+ height: 100%;
+
+ .error {
+ width: 100%;
+ }
+ }
+ }
+
+ @media all and (max-width: 800px) {
+ height: 100%;
+ overflow: hidden;
+
+ .chat-view-inner {
+ overflow: hidden;
+ height: 100%;
+ margin-top: 0;
+ margin-left: 0;
+ margin-right: 0;
+ }
+
+ .chat-view-body {
+ display: flex;
+ min-height: auto;
+ overflow: hidden;
+ height: 100%;
+ margin: 0;
+ border-radius: 0 !important;
+ }
+
+ .chat-view-heading {
+ position: static;
+ z-index: 9999;
+ top: 0;
+ margin-top: 0;
+ border-radius: 0;
+ }
+
+ .scrollable-message-list {
+ display: unset;
+ overflow-y: scroll;
+ overflow-x: hidden;
+ -webkit-overflow-scrolling: touch;
+ }
+
+ .footer {
+ position: sticky;
+ bottom: auto;
+ }
+ }
+}
diff --git a/src/components/chat/chat.vue b/src/components/chat/chat.vue
new file mode 100644
index 00000000..d8c91dbe
--- /dev/null
+++ b/src/components/chat/chat.vue
@@ -0,0 +1,99 @@
+<template>
+ <div class="chat-view">
+ <div class="chat-view-inner">
+ <div
+ id="nav"
+ ref="inner"
+ class="panel-default panel chat-view-body"
+ >
+ <div
+ ref="header"
+ class="panel-heading chat-view-heading mobile-hidden"
+ >
+ <a
+ class="go-back-button"
+ @click="goBack"
+ >
+ <i class="button-icon icon-left-open" />
+ </a>
+ <div class="title text-center">
+ <ChatTitle
+ :user="recipient"
+ :with-avatar="true"
+ />
+ </div>
+ </div>
+ <template>
+ <div
+ ref="scrollable"
+ class="scrollable-message-list"
+ :style="{ height: scrollableContainerHeight }"
+ @scroll="handleScroll"
+ >
+ <template v-if="!errorLoadingChat">
+ <ChatMessage
+ v-for="chatViewItem in chatViewItems"
+ :key="chatViewItem.id"
+ :author="recipient"
+ :chat-view-item="chatViewItem"
+ :hovered-message-chain="chatViewItem.messageChainId === hoveredMessageChainId"
+ @hover="onMessageHover"
+ />
+ </template>
+ <div
+ v-else
+ class="chat-loading-error"
+ >
+ <div class="alert error">
+ {{ $t('chats.error_loading_chat') }}
+ </div>
+ </div>
+ </div>
+ <div
+ ref="footer"
+ class="panel-body footer"
+ >
+ <div
+ class="jump-to-bottom-button"
+ :class="{ 'visible': jumpToBottomButtonVisible }"
+ @click="scrollDown({ behavior: 'smooth' })"
+ >
+ <i class="icon-down-open">
+ <div
+ v-if="newMessageCount"
+ class="badge badge-notification unread-chat-count unread-message-count"
+ >
+ {{ newMessageCount }}
+ </div>
+ </i>
+ </div>
+ <PostStatusForm
+ :disable-subject="true"
+ :disable-scope-selector="true"
+ :disable-notice="true"
+ :disable-lock-warning="true"
+ :disable-polls="true"
+ :disable-sensitivity-checkbox="true"
+ :disable-submit="errorLoadingChat || !currentChat"
+ :request="sendMessage"
+ :submit-on-enter="!mobileLayout"
+ :preserve-focus="!mobileLayout"
+ :auto-focus="!mobileLayout"
+ :placeholder="formPlaceholder"
+ :file-limit="1"
+ max-height="160"
+ emoji-picker-placement="top"
+ @resize="handleResize"
+ />
+ </div>
+ </template>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script src="./chat.js"></script>
+<style lang="scss">
+@import '../../_variables.scss';
+@import './chat.scss';
+</style>
diff --git a/src/components/chat/chat_layout.js b/src/components/chat/chat_layout.js
new file mode 100644
index 00000000..07ae3abf
--- /dev/null
+++ b/src/components/chat/chat_layout.js
@@ -0,0 +1,100 @@
+const ChatLayout = {
+ methods: {
+ setChatLayout () {
+ if (this.mobileLayout) {
+ this.setMobileChatLayout()
+ }
+ },
+ unsetChatLayout () {
+ this.unsetMobileChatLayout()
+ },
+ setMobileChatLayout () {
+ // 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.style.overflow = 'hidden'
+ html.style.height = '100%'
+ }
+
+ let body = document.querySelector('body')
+ if (body) {
+ body.style.height = '100%'
+ }
+
+ let app = document.getElementById('app')
+ if (app) {
+ app.style.height = '100%'
+ app.style.overflow = 'hidden'
+ app.style.minHeight = 'auto'
+ }
+
+ let appBgWrapper = window.document.getElementById('app_bg_wrapper')
+ if (appBgWrapper) {
+ appBgWrapper.style.overflow = 'hidden'
+ }
+
+ let main = document.getElementsByClassName('main')[0]
+ if (main) {
+ main.style.overflow = 'hidden'
+ main.style.height = '100%'
+ }
+
+ let content = document.getElementById('content')
+ if (content) {
+ content.style.paddingTop = '0'
+ content.style.height = '100%'
+ content.style.overflow = 'visible'
+ }
+
+ this.$nextTick(() => {
+ this.updateScrollableContainerHeight()
+ })
+ },
+ unsetMobileChatLayout () {
+ let html = document.querySelector('html')
+ if (html) {
+ html.style.overflow = 'visible'
+ html.style.height = 'unset'
+ }
+
+ let body = document.querySelector('body')
+ if (body) {
+ body.style.height = 'unset'
+ }
+
+ let app = document.getElementById('app')
+ if (app) {
+ app.style.height = '100%'
+ app.style.overflow = 'visible'
+ app.style.minHeight = '100vh'
+ }
+
+ let appBgWrapper = document.getElementById('app_bg_wrapper')
+ if (appBgWrapper) {
+ appBgWrapper.style.overflow = 'visible'
+ }
+
+ let main = document.getElementsByClassName('main')[0]
+ if (main) {
+ main.style.overflow = 'visible'
+ main.style.height = 'unset'
+ }
+
+ let content = document.getElementById('content')
+ if (content) {
+ content.style.paddingTop = '60px'
+ content.style.height = 'unset'
+ content.style.overflow = 'unset'
+ }
+ }
+ }
+}
+
+export default ChatLayout
diff --git a/src/components/chat/chat_layout_utils.js b/src/components/chat/chat_layout_utils.js
new file mode 100644
index 00000000..f07ba2a1
--- /dev/null
+++ b/src/components/chat/chat_layout_utils.js
@@ -0,0 +1,27 @@
+// Captures a scroll position
+export const getScrollPosition = (el) => {
+ return {
+ scrollTop: el.scrollTop,
+ scrollHeight: el.scrollHeight,
+ offsetHeight: el.offsetHeight
+ }
+}
+
+// A helper function that is used to keep the scroll position fixed as the new elements are added to the top
+// Takes two scroll positions, before and after the update.
+export const getNewTopPosition = (previousPosition, newPosition) => {
+ return previousPosition.scrollTop + (newPosition.scrollHeight - previousPosition.scrollHeight)
+}
+
+export const isBottomedOut = (el, offset = 0) => {
+ if (!el) { return }
+ const scrollHeight = el.scrollTop + offset
+ const totalHeight = el.scrollHeight - el.offsetHeight
+ return totalHeight <= scrollHeight
+}
+
+// Height of the scrollable container. The dynamic height is needed to ensure the mobile browser panel doesn't overlap or hide the posting form.
+export const scrollableContainerHeight = (inner, header, footer) => {
+ const height = parseFloat(getComputedStyle(inner, null).height.replace('px', ''))
+ return height - header.clientHeight - footer.clientHeight
+}