From aa2cf51c05ebdf411d74af5debbbc8fa4d3cf457 Mon Sep 17 00:00:00 2001 From: eugenijm <eugenijm@protonmail.com> Date: Thu, 7 May 2020 16:10:53 +0300 Subject: [PATCH 1/8] Add Chats --- src/App.js | 16 +- src/App.scss | 14 + src/App.vue | 3 +- src/_variables.scss | 1 + src/boot/after_store.js | 1 + src/boot/routes.js | 15 +- .../account_actions/account_actions.js | 12 + .../account_actions/account_actions.vue | 7 + src/components/chat/chat.js | 304 ++++++++++++++++++ src/components/chat/chat.scss | 161 ++++++++++ src/components/chat/chat.vue | 99 ++++++ src/components/chat/chat_layout.js | 100 ++++++ src/components/chat/chat_layout_utils.js | 27 ++ src/components/chat_avatar/chat_avatar.js | 23 ++ src/components/chat_avatar/chat_avatar.vue | 53 +++ src/components/chat_list/chat_list.js | 37 +++ src/components/chat_list/chat_list.vue | 48 +++ .../chat_list_item/chat_list_item.js | 65 ++++ .../chat_list_item/chat_list_item.scss | 94 ++++++ .../chat_list_item/chat_list_item.vue | 49 +++ src/components/chat_message/chat_message.js | 109 +++++++ src/components/chat_message/chat_message.scss | 157 +++++++++ src/components/chat_message/chat_message.vue | 99 ++++++ .../chat_message_date/chat_message_date.vue | 24 ++ src/components/chat_new/chat_new.js | 74 +++++ src/components/chat_new/chat_new.scss | 29 ++ src/components/chat_new/chat_new.vue | 46 +++ src/components/chat_panel/chat_panel.vue | 94 +++--- src/components/chat_title/chat_title.js | 20 ++ src/components/chat_title/chat_title.vue | 56 ++++ src/components/emoji_input/emoji_input.js | 38 ++- src/components/emoji_input/emoji_input.vue | 5 +- .../features_panel/features_panel.js | 1 + .../features_panel/features_panel.vue | 3 + src/components/media_upload/media_upload.js | 3 +- src/components/media_upload/media_upload.vue | 21 +- src/components/mobile_nav/mobile_nav.js | 5 +- src/components/mobile_nav/mobile_nav.vue | 1 + .../mobile_post_status_button.js | 7 + src/components/nav_panel/nav_panel.js | 20 +- src/components/nav_panel/nav_panel.vue | 11 + src/components/notification/notification.js | 6 +- src/components/notifications/notifications.js | 9 +- .../post_status_form/post_status_form.js | 138 +++++--- .../post_status_form/post_status_form.vue | 50 ++- .../tabs/theme_tab/theme_tab.js | 6 +- .../tabs/theme_tab/theme_tab.vue | 67 ++++ src/components/side_drawer/side_drawer.js | 7 +- src/components/side_drawer/side_drawer.vue | 22 +- .../status_content/status_content.js | 2 +- .../status_content/status_content.vue | 2 +- src/hocs/with_load_more/with_load_more.scss | 4 + src/i18n/en.json | 33 +- src/main.js | 4 +- src/modules/api.js | 24 +- src/modules/chats.js | 228 +++++++++++++ src/modules/config.js | 3 +- src/modules/instance.js | 1 + src/modules/interface.js | 9 +- src/modules/statuses.js | 5 +- src/modules/users.js | 5 + src/services/api/api.service.js | 117 ++++++- src/services/chat_service/chat_service.js | 150 +++++++++ .../entity_normalizer.service.js | 34 +- src/services/style_setter/style_setter.js | 3 +- src/services/theme_data/pleromafe.js | 53 ++- src/services/window_utils/window_utils.js | 5 + static/fontello.json | 6 + test/unit/specs/boot/routes.spec.js | 10 +- 69 files changed, 2794 insertions(+), 161 deletions(-) create mode 100644 src/components/chat/chat.js create mode 100644 src/components/chat/chat.scss create mode 100644 src/components/chat/chat.vue create mode 100644 src/components/chat/chat_layout.js create mode 100644 src/components/chat/chat_layout_utils.js create mode 100644 src/components/chat_avatar/chat_avatar.js create mode 100644 src/components/chat_avatar/chat_avatar.vue create mode 100644 src/components/chat_list/chat_list.js create mode 100644 src/components/chat_list/chat_list.vue create mode 100644 src/components/chat_list_item/chat_list_item.js create mode 100644 src/components/chat_list_item/chat_list_item.scss create mode 100644 src/components/chat_list_item/chat_list_item.vue create mode 100644 src/components/chat_message/chat_message.js create mode 100644 src/components/chat_message/chat_message.scss create mode 100644 src/components/chat_message/chat_message.vue create mode 100644 src/components/chat_message_date/chat_message_date.vue create mode 100644 src/components/chat_new/chat_new.js create mode 100644 src/components/chat_new/chat_new.scss create mode 100644 src/components/chat_new/chat_new.vue create mode 100644 src/components/chat_title/chat_title.js create mode 100644 src/components/chat_title/chat_title.vue create mode 100644 src/modules/chats.js create mode 100644 src/services/chat_service/chat_service.js diff --git a/src/App.js b/src/App.js index 92c4e2f5..84300e00 100644 --- a/src/App.js +++ b/src/App.js @@ -14,7 +14,7 @@ import MobileNav from './components/mobile_nav/mobile_nav.vue' import UserReportingModal from './components/user_reporting_modal/user_reporting_modal.vue' import PostStatusModal from './components/post_status_modal/post_status_modal.vue' import GlobalNoticeList from './components/global_notice_list/global_notice_list.vue' -import { windowWidth } from './services/window_utils/window_utils' +import { windowWidth, windowHeight } from './services/window_utils/window_utils' export default { name: 'app', @@ -45,7 +45,8 @@ export default { window.CSS.supports('-moz-mask-size', 'contain') || window.CSS.supports('-ms-mask-size', 'contain') || window.CSS.supports('-o-mask-size', 'contain') - ) + ), + transitionName: 'fade' }), created () { // Load the locale from the storage @@ -127,10 +128,21 @@ export default { }, updateMobileState () { const mobileLayout = windowWidth() <= 800 + const layoutHeight = windowHeight() const changed = mobileLayout !== this.isMobileLayout if (changed) { this.$store.dispatch('setMobileLayout', mobileLayout) } + this.$store.dispatch('setLayoutHeight', layoutHeight) + } + }, + watch: { + '$route' (to, from) { + if ((to.name === 'chat' && from.name === 'chats') || (to.name === 'chats' && from.name === 'chat')) { + this.transitionName = 'none' + } else { + this.transitionName = 'fade' + } } } } diff --git a/src/App.scss b/src/App.scss index 6597b6f4..29ce73a8 100644 --- a/src/App.scss +++ b/src/App.scss @@ -56,6 +56,7 @@ body { overflow-x: hidden; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + overscroll-behavior: none; &.hidden { display: none; @@ -928,3 +929,16 @@ nav { background-color: $fallback--fg; background-color: var(--panel, $fallback--fg); } + +.unread-chat-count { + font-size: 0.9em; + font-weight: bolder; + font-style: normal; + position: absolute; + right: 0.6rem; + padding: 0 0.3em; + min-width: 1.3rem; + min-height: 1.3rem; + max-height: 1.3rem; + line-height: 1.3rem; +} diff --git a/src/App.vue b/src/App.vue index 03b632ec..5d429934 100644 --- a/src/App.vue +++ b/src/App.vue @@ -77,6 +77,7 @@ </div> </div> </nav> + <div class="app-bg-wrapper app-container-wrapper" /> <div id="content" class="container underlay" @@ -112,7 +113,7 @@ {{ $t("login.hint") }} </router-link> </div> - <transition name="fade"> + <transition :name="transitionName"> <router-view /> </transition> </div> diff --git a/src/_variables.scss b/src/_variables.scss index 30dc3e42..9004d551 100644 --- a/src/_variables.scss +++ b/src/_variables.scss @@ -27,5 +27,6 @@ $fallback--tooltipRadius: 5px; $fallback--avatarRadius: 4px; $fallback--avatarAltRadius: 10px; $fallback--attachmentRadius: 10px; +$fallback--chatMessageRadius: 10px; $fallback--buttonShadow: 0px 0px 2px 0px rgba(0, 0, 0, 1), 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset, 0px -1px 0px 0px rgba(0, 0, 0, 0.2) inset; diff --git a/src/boot/after_store.js b/src/boot/after_store.js index 302b278c..7160434f 100644 --- a/src/boot/after_store.js +++ b/src/boot/after_store.js @@ -230,6 +230,7 @@ const getNodeInfo = async ({ store }) => { store.dispatch('setInstanceOption', { name: 'mediaProxyAvailable', value: features.includes('media_proxy') }) store.dispatch('setInstanceOption', { name: 'safeDM', value: features.includes('safe_dm_mentions') }) store.dispatch('setInstanceOption', { name: 'chatAvailable', value: features.includes('chat') }) + store.dispatch('setInstanceOption', { name: 'pleromaChatMessagesAvailable', value: features.includes('pleroma_chat_messages') }) store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') }) store.dispatch('setInstanceOption', { name: 'pollsAvailable', value: features.includes('polls') }) store.dispatch('setInstanceOption', { name: 'pollLimits', value: metadata.pollLimits }) diff --git a/src/boot/routes.js b/src/boot/routes.js index f63d8adf..b5d3c631 100644 --- a/src/boot/routes.js +++ b/src/boot/routes.js @@ -6,6 +6,8 @@ import BookmarkTimeline from 'components/bookmark_timeline/bookmark_timeline.vue import ConversationPage from 'components/conversation-page/conversation-page.vue' import Interactions from 'components/interactions/interactions.vue' import DMs from 'components/dm_timeline/dm_timeline.vue' +import ChatList from 'components/chat_list/chat_list.vue' +import Chat from 'components/chat/chat.vue' import UserProfile from 'components/user_profile/user_profile.vue' import Search from 'components/search/search.vue' import Registration from 'components/registration/registration.vue' @@ -28,7 +30,7 @@ export default (store) => { } } - return [ + let routes = [ { name: 'root', path: '/', redirect: _to => { @@ -62,11 +64,20 @@ export default (store) => { { name: 'friend-requests', path: '/friend-requests', component: FollowRequests, beforeEnter: validateAuthenticatedRoute }, { name: 'notifications', path: '/:username/notifications', component: Notifications, beforeEnter: validateAuthenticatedRoute }, { name: 'login', path: '/login', component: AuthForm }, - { name: 'chat', path: '/chat', component: ChatPanel, props: () => ({ floating: false }) }, + { name: 'chat-panel', path: '/chat-panel', component: ChatPanel, props: () => ({ floating: false }) }, { name: 'oauth-callback', path: '/oauth-callback', component: OAuthCallback, props: (route) => ({ code: route.query.code }) }, { name: 'search', path: '/search', component: Search, props: (route) => ({ query: route.query.query }) }, { name: 'who-to-follow', path: '/who-to-follow', component: WhoToFollow, beforeEnter: validateAuthenticatedRoute }, { name: 'about', path: '/about', component: About }, { name: 'user-profile', path: '/(users/)?:name', component: UserProfile } ] + + if (store.state.instance.pleromaChatMessagesAvailable) { + routes = routes.concat([ + { name: 'chat', path: '/users/:username/chats/:recipient_id', component: Chat, meta: { dontScroll: false }, beforeEnter: validateAuthenticatedRoute }, + { name: 'chats', path: '/users/:username/chats', component: ChatList, meta: { dontScroll: false }, beforeEnter: validateAuthenticatedRoute } + ]) + } + + return routes } diff --git a/src/components/account_actions/account_actions.js b/src/components/account_actions/account_actions.js index 0826c275..6d345bc7 100644 --- a/src/components/account_actions/account_actions.js +++ b/src/components/account_actions/account_actions.js @@ -1,3 +1,4 @@ +import { mapState } from 'vuex' import ProgressButton from '../progress_button/progress_button.vue' import Popover from '../popover/popover.vue' @@ -27,7 +28,18 @@ const AccountActions = { }, reportUser () { this.$store.dispatch('openUserReportingModal', this.user.id) + }, + openChat () { + this.$router.push({ + name: 'chat', + params: { recipient_id: this.user.id } + }) } + }, + computed: { + ...mapState({ + pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable + }) } } diff --git a/src/components/account_actions/account_actions.vue b/src/components/account_actions/account_actions.vue index 029e7096..987e94b7 100644 --- a/src/components/account_actions/account_actions.vue +++ b/src/components/account_actions/account_actions.vue @@ -50,6 +50,13 @@ > {{ $t('user_card.report') }} </button> + <button + v-if="pleromaChatMessagesAvailable" + class="btn btn-default btn-block dropdown-item" + @click="openChat" + > + {{ $t('user_card.message') }} + </button> </div> </div> <div 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 +} diff --git a/src/components/chat_avatar/chat_avatar.js b/src/components/chat_avatar/chat_avatar.js new file mode 100644 index 00000000..7b26e07c --- /dev/null +++ b/src/components/chat_avatar/chat_avatar.js @@ -0,0 +1,23 @@ +import StillImage from '../still-image/still-image.vue' +import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' +import { mapState } from 'vuex' + +const ChatAvatar = { + props: ['user', 'width', 'height'], + components: { + StillImage + }, + methods: { + getUserProfileLink (user) { + if (!user) { return } + return generateProfileLink(user.id, user.screen_name) + } + }, + computed: { + ...mapState({ + betterShadow: state => state.interface.browserSupport.cssFilter + }) + } +} + +export default ChatAvatar diff --git a/src/components/chat_avatar/chat_avatar.vue b/src/components/chat_avatar/chat_avatar.vue new file mode 100644 index 00000000..f54a7151 --- /dev/null +++ b/src/components/chat_avatar/chat_avatar.vue @@ -0,0 +1,53 @@ +<template> + <router-link + :to="getUserProfileLink(user) || ''" + > + <StillImage + v-if="user" + :style="{ 'width': width, 'height': height }" + class="avatar chat-avatar single-user" + :alt="user.screen_name" + :title="user.screen_name" + :src="user.profile_image_url_original" + error-src="/images/avi.png" + :class="{ 'better-shadow': betterShadow }" + /> + <div + v-else + class="avatar chat-avatar single-user" + :style="{ 'width': width, 'height': height }" + /> + </router-link> +</template> + +<script src="./chat_avatar.js"></script> +<style lang="scss"> +@import '../../_variables.scss'; + +.chat-avatar { + display: inline-block; + vertical-align: middle; + + &.single-user { + border-radius: $fallback--avatarAltRadius; + border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius); + } + + .avatar.still-image { + width: 48px; + height: 48px; + + box-shadow: var(--avatarStatusShadow); + border-radius: 0; + + &.better-shadow { + box-shadow: var(--avatarStatusShadowInset); + filter: var(--avatarStatusShadowFilter) + } + + &.animated::before { + display: none; + } + } +} +</style> diff --git a/src/components/chat_list/chat_list.js b/src/components/chat_list/chat_list.js new file mode 100644 index 00000000..95708d1d --- /dev/null +++ b/src/components/chat_list/chat_list.js @@ -0,0 +1,37 @@ +import { mapState, mapGetters } from 'vuex' +import ChatListItem from '../chat_list_item/chat_list_item.vue' +import ChatNew from '../chat_new/chat_new.vue' +import List from '../list/list.vue' + +const ChatList = { + components: { + ChatListItem, + List, + ChatNew + }, + computed: { + ...mapState({ + currentUser: state => state.users.currentUser + }), + ...mapGetters(['sortedChatList']) + }, + data () { + return { + isNew: false + } + }, + created () { + this.$store.dispatch('fetchChats', { latest: true }) + }, + methods: { + cancelNewChat () { + this.isNew = false + this.$store.dispatch('fetchChats', { latest: true }) + }, + newChat () { + this.isNew = true + } + } +} + +export default ChatList diff --git a/src/components/chat_list/chat_list.vue b/src/components/chat_list/chat_list.vue new file mode 100644 index 00000000..e62f58e5 --- /dev/null +++ b/src/components/chat_list/chat_list.vue @@ -0,0 +1,48 @@ +<template> + <div v-if="isNew"> + <ChatNew @cancel="cancelNewChat" /> + </div> + <div + v-else + class="chat-list panel panel-default" + > + <div class="panel-heading"> + <span class="title"> + {{ $t("chats.chats") }} + </span> + <button @click="newChat"> + {{ $t("chats.new") }} + </button> + </div> + <div class="panel-body"> + <div class="timeline"> + <List :items="sortedChatList"> + <template + slot="item" + slot-scope="{item}" + > + <ChatListItem + :key="item.id" + :compact="false" + :chat="item" + /> + </template> + </List> + </div> + </div> + </div> +</template> + +<script src="./chat_list.js"></script> + +<style lang="scss"> +@import '../../_variables.scss'; + +.chat-list { + min-height: calc(100vh - 67px); + margin-bottom: 0; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +</style> diff --git a/src/components/chat_list_item/chat_list_item.js b/src/components/chat_list_item/chat_list_item.js new file mode 100644 index 00000000..1c27088c --- /dev/null +++ b/src/components/chat_list_item/chat_list_item.js @@ -0,0 +1,65 @@ +import { mapState } from 'vuex' +import StatusContent from '../status_content/status_content.vue' +import fileType from 'src/services/file_type/file_type.service' +import ChatAvatar from '../chat_avatar/chat_avatar.vue' +import AvatarList from '../avatar_list/avatar_list.vue' +import Timeago from '../timeago/timeago.vue' +import ChatTitle from '../chat_title/chat_title.vue' + +const ChatListItem = { + name: 'ChatListItem', + props: [ + 'chat' + ], + components: { + ChatAvatar, + AvatarList, + Timeago, + ChatTitle, + StatusContent + }, + computed: { + ...mapState({ + currentUser: state => state.users.currentUser + }), + attachmentInfo () { + if (this.chat.lastMessage.attachments.length === 0) { return } + + const types = this.chat.lastMessage.attachments.map(file => fileType.fileType(file.mimetype)) + if (types.includes('video')) { + return this.$t('file_type.video') + } else if (types.includes('audio')) { + return this.$t('file_type.audio') + } else if (types.includes('image')) { + return this.$t('file_type.image') + } else { + return this.$t('file_type.file') + } + }, + messageForStatusContent () { + const content = this.chat.lastMessage ? (this.attachmentInfo || this.chat.lastMessage.content) : '' + + return { + summary: '', + statusnet_html: content, + text: content, + attachments: [] + } + } + }, + methods: { + openChat (_e) { + if (this.chat.id) { + this.$router.push({ + name: 'chat', + params: { + username: this.currentUser.screen_name, + recipient_id: this.chat.account.id + } + }) + } + } + } +} + +export default ChatListItem diff --git a/src/components/chat_list_item/chat_list_item.scss b/src/components/chat_list_item/chat_list_item.scss new file mode 100644 index 00000000..12269f89 --- /dev/null +++ b/src/components/chat_list_item/chat_list_item.scss @@ -0,0 +1,94 @@ +.chat-list-item { + &:hover .animated.avatar { + canvas { + display: none; + } + img { + visibility: visible; + } + } + + display: flex; + flex-direction: row; + padding: 0.75em; + height: 4.85em; + overflow: hidden; + box-sizing: border-box; + cursor: pointer; + + :focus { + outline: none; + } + + &:hover { + background-color: var(--selectedPost, $fallback--lightBg); + box-shadow: 0 0px 3px 1px rgba(0, 0, 0, 0.1); + } + + .chat-list-item-left { + margin-right: 1em; + } + + .chat-list-item-center { + width: 100%; + box-sizing: border-box; + overflow: hidden; + word-wrap: break-word; + } + + .heading { + width: 100%; + display: inline-flex; + justify-content: space-between; + line-height: 1em; + } + + .heading-right { + white-space: nowrap; + } + + .member-count { + color: $fallback--text; + color: var(--faintText, $fallback--text); + margin-right: 2px; + } + + .name-and-account-name { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + flex-shrink: 1; + } + + .chat-preview { + display: inline-flex; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + margin: 0.35rem 0; + height: 1.2em; + line-height: 1.2em; + color: $fallback--text; + color: var(--faint, $fallback--text); + } + + a { + color: var(--faintLink, $fallback--link); + text-decoration: none; + pointer-events: none; + } + + .unread-indicator-wrapper { + display: flex; + align-items: center; + margin-left: 10px; + } + + .unread-indicator { + border-radius: 100%; + height: 8px; + width: 8px; + background-color: $fallback--link; + background-color: var(--link, $fallback--link); + } +} diff --git a/src/components/chat_list_item/chat_list_item.vue b/src/components/chat_list_item/chat_list_item.vue new file mode 100644 index 00000000..26ad581b --- /dev/null +++ b/src/components/chat_list_item/chat_list_item.vue @@ -0,0 +1,49 @@ +<template> + <div + class="chat-list-item" + @click.capture.prevent="openChat" + > + <div class="chat-list-item-left"> + <ChatAvatar + :user="chat.account" + height="48px" + width="48px" + /> + </div> + <div class="chat-list-item-center"> + <div class="heading"> + <span + v-if="chat.account" + class="name-and-account-name" + > + <ChatTitle + :user="chat.account" + /> + </span> + <span class="heading-right" /> + </div> + <div class="chat-preview"> + <StatusContent :status="messageForStatusContent" /> + <div + v-if="chat.unread > 0" + class="badge badge-notification unread-chat-count" + > + {{ chat.unread }} + </div> + </div> + </div> + <div> + <Timeago + :time="chat.updated_at" + :auto-update="60" + /> + </div> + </div> +</template> + +<script src="./chat_list_item.js"></script> + +<style lang="scss"> +@import '../../_variables.scss'; +@import './chat_list_item.scss'; +</style> diff --git a/src/components/chat_message/chat_message.js b/src/components/chat_message/chat_message.js new file mode 100644 index 00000000..aba95074 --- /dev/null +++ b/src/components/chat_message/chat_message.js @@ -0,0 +1,109 @@ +import { mapState, mapGetters } from 'vuex' +import Popover from '../popover/popover.vue' +import Attachment from '../attachment/attachment.vue' +import UserAvatar from '../user_avatar/user_avatar.vue' +import Gallery from '../gallery/gallery.vue' +import LinkPreview from '../link-preview/link-preview.vue' +import StatusContent from '../status_content/status_content.vue' +import ChatMessageDate from '../chat_message_date/chat_message_date.vue' +import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' + +const ChatMessage = { + name: 'ChatMessage', + props: [ + 'author', + 'edited', + 'noHeading', + 'chatViewItem', + 'hoveredMessageChain' + ], + components: { + Popover, + Attachment, + StatusContent, + UserAvatar, + Gallery, + LinkPreview, + ChatMessageDate + }, + computed: { + // Returns HH:MM (hours and minutes) in local time. + createdAt () { + const time = this.chatViewItem.data.created_at + return time.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false }) + }, + isCurrentUser () { + return this.message.account_id === this.currentUser.id + }, + message () { + return this.chatViewItem.data + }, + userProfileLink () { + return generateProfileLink(this.author.id, this.author.screen_name, this.$store.state.instance.restrictedNicknames) + }, + isMessage () { + return this.chatViewItem.type === 'message' + }, + messageForStatusContent () { + return { + summary: '', + statusnet_html: this.message.content, + text: this.message.content, + attachments: this.message.attachments + } + }, + hasAttachment () { + return this.message.attachments.length > 0 + }, + ...mapState({ + betterShadow: state => state.interface.browserSupport.cssFilter, + currentUser: state => state.users.currentUser, + restrictedNicknames: state => state.instance.restrictedNicknames + }), + ellipsisButtonWrapperStyle () { + let res = { + 'opacity': this.hovered || this.menuOpened ? '1' : '0' + } + + if (this.isCurrentUser) { + res.right = '5px' + } else { + res.left = '5px' + } + + return res + }, + popoverMarginStyle () { + if (this.isCurrentUser) { + return {} + } else { + return { left: 50 } + } + }, + ...mapGetters(['mergedConfig', 'findUser']) + }, + data () { + return { + hovered: false, + menuOpened: false + } + }, + methods: { + onHover (bool) { + this.$emit('hover', { isHovered: bool, messageChainId: this.chatViewItem.messageChainId }) + }, + async deleteMessage () { + const confirmed = window.confirm(this.$t('chats.delete_confirm')) + if (confirmed) { + await this.$store.dispatch('deleteChatMessage', { + messageId: this.chatViewItem.data.id, + chatId: this.chatViewItem.data.chat_id + }) + } + this.hovered = false + this.menuOpened = false + } + } +} + +export default ChatMessage diff --git a/src/components/chat_message/chat_message.scss b/src/components/chat_message/chat_message.scss new file mode 100644 index 00000000..e4028537 --- /dev/null +++ b/src/components/chat_message/chat_message.scss @@ -0,0 +1,157 @@ +@import '../../_variables.scss'; + +.chat-message-wrapper { + &.hovered-message-chain { + .animated.avatar { + canvas { + display: none; + } + img { + visibility: visible; + } + } + } + + &:last-child { + margin-bottom: 16px; + } + + .chat-message-menu { + transition: opacity 0.1s; + opacity: 0; + position: absolute; + top: -10px; + + button { + padding-top: 3px; + padding-bottom: 3px; + } + } + + .icon-ellipsis { + cursor: pointer; + + &:hover, .extra-button-popover.open & { + color: $fallback--text; + color: var(--text, $fallback--text); + } + + border-radius: $fallback--chatMessageRadius; + border-radius: var(--chatMessageRadius, $fallback--chatMessageRadius); + } + + .popover { + width: 12rem; + } + + .chat-message { + display: flex; + padding-bottom: 7px; + } + + .avatar-wrapper { + margin-right: 10px; + width: 32px; + } + + .link-preview, .attachments { + margin-bottom: 0.9em; + } + + .chat-message-inner { + display: flex; + flex-direction: column; + align-items: flex-start; + max-width: 80%; + min-width: 10rem; + width: 100%; + + &.with-media { + width: 100%; + + .gallery-row { + overflow: hidden; + } + + .status { + width: 100%; + } + } + } + + .status { + border-radius: $fallback--chatMessageRadius; + border-radius: var(--chatMessageRadius, $fallback--chatMessageRadius); + display: flex; + padding: 0.75em; + } + + .created-at { + float: right; + font-size: 0.8em; + margin: -10px 0 -5px 4px; + font-style: italic; + opacity: 0.8; + } + + .without-attachment { + .status-content { + white-space: normal; + + &::after { + margin-right: 75px; + content: " "; + display: inline-block; + } + } + } + + .incoming { + a { + color: var(--chatMessageIncomingLink, $fallback--link); + } + + .status { + color: var(--chatMessageIncomingText, $fallback--text); + background-color: var(--chatMessageIncomingBg, $fallback--bg); + border: 1px solid var(--chatMessageIncomingBorder, --border); + } + + .created-at { + a { + color: var(--chatMessageIncomingText, $fallback--text); + } + } + } + + .outgoing { + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-content: end; + justify-content: flex-end; + + a { + color: var(--chatMessageOutgoingLink, $fallback--link); + } + + .status { + color: var(--chatMessageOutgoingText, $fallback--text); + background-color: var(--chatMessageOutgoingBg, $fallback--lightBg); + border: 1px solid var(--chatMessageOutgoingBorder, --lightBg); + } + + .chat-message-inner { + align-items: flex-end; + } + } +} + +.chat-message-date-separator { + text-align: center; + margin: 1.4em 0; + font-size: 0.9em; + user-select: none; + color: $fallback--text; + color: var(--faintedText, $fallback--text); +} diff --git a/src/components/chat_message/chat_message.vue b/src/components/chat_message/chat_message.vue new file mode 100644 index 00000000..872ddf70 --- /dev/null +++ b/src/components/chat_message/chat_message.vue @@ -0,0 +1,99 @@ +<template> + <div + v-if="isMessage" + class="chat-message-wrapper" + :class="{ 'hovered-message-chain': hoveredMessageChain }" + @mouseover="onHover(true)" + @mouseleave="onHover(false)" + > + <div + class="chat-message" + :class="[{ 'outgoing': isCurrentUser, 'incoming': !isCurrentUser }]" + > + <div + v-if="!isCurrentUser" + class="avatar-wrapper" + > + <router-link + v-if="chatViewItem.isHead" + :to="userProfileLink" + > + <UserAvatar + :compact="true" + :better-shadow="betterShadow" + :user="author" + /> + </router-link> + </div> + <div class="chat-message-inner"> + <div + class="status-body" + :style="{ 'min-width': message.attachment ? '80%' : '' }" + > + <div + class="media status" + :class="{ 'without-attachment': !hasAttachment }" + style="position: relative" + @mouseenter="hovered = true" + @mouseleave="hovered = false" + > + <div + class="chat-message-menu" + :style="ellipsisButtonWrapperStyle" + > + <Popover + trigger="click" + placement="top" + :bound-to-selector="isCurrentUser ? '' : '.scrollable-message-list'" + :bound-to="{ x: 'container' }" + :margin="popoverMarginStyle" + @show="menuOpened = true" + @close="menuOpened = false" + > + <div slot="content"> + <div class="dropdown-menu"> + <button + class="dropdown-item dropdown-item-icon" + @click="deleteMessage" + > + <i class="icon-cancel" /> {{ $t("chats.delete") }} + </button> + </div> + </div> + <button + slot="trigger" + :title="$t('chats.more')" + > + <i class="icon-ellipsis" /> + </button> + </Popover> + </div> + <StatusContent + :status="messageForStatusContent" + :full-content="true" + > + <span + slot="footer" + class="created-at" + > + {{ createdAt }} + </span> + </StatusContent> + </div> + </div> + </div> + </div> + </div> + <div + v-else + class="chat-message-date-separator" + > + <ChatMessageDate :date="chatViewItem.date" /> + </div> +</template> + +<script src="./chat_message.js" ></script> +<style lang="scss"> +@import './chat_message.scss'; + +</style> diff --git a/src/components/chat_message_date/chat_message_date.vue b/src/components/chat_message_date/chat_message_date.vue new file mode 100644 index 00000000..79c346b6 --- /dev/null +++ b/src/components/chat_message_date/chat_message_date.vue @@ -0,0 +1,24 @@ +<template> + <time> + {{ displayDate }} + </time> +</template> + +<script> +export default { + name: 'Timeago', + props: ['date'], + computed: { + displayDate () { + const today = new Date() + today.setHours(0, 0, 0, 0) + + if (this.date.getTime() === today.getTime()) { + return this.$t('display_date.today') + } else { + return this.date.toLocaleDateString('en', { day: 'numeric', month: 'long' }) + } + } + } +} +</script> diff --git a/src/components/chat_new/chat_new.js b/src/components/chat_new/chat_new.js new file mode 100644 index 00000000..0da681f7 --- /dev/null +++ b/src/components/chat_new/chat_new.js @@ -0,0 +1,74 @@ +import { throttle } from 'lodash' +import { mapState, mapGetters } from 'vuex' +import BasicUserCard from '../basic_user_card/basic_user_card.vue' +import UserAvatar from '../user_avatar/user_avatar.vue' + +const chatNew = { + components: { + BasicUserCard, + UserAvatar + }, + data () { + return { + suggestions: [], + userIds: [], + loading: false, + query: '' + } + }, + async created () { + const { chats } = await this.backendInteractor.chats() + chats.forEach(chat => this.suggestions.push(chat.account)) + }, + computed: { + users () { + return this.userIds.map(userId => this.findUser(userId)) + }, + availableUsers () { + if (this.query.length !== 0) { + return this.users + } else { + return this.suggestions + } + }, + ...mapState({ + currentUser: state => state.users.currentUser, + backendInteractor: state => state.api.backendInteractor + }), + ...mapGetters(['findUser']) + }, + methods: { + goBack () { + this.$emit('cancel') + }, + goToChat (user) { + this.$router.push({ name: 'chat', params: { recipient_id: user.id } }) + }, + onInput () { + this.search(this.query) + }, + addUser (user) { + this.selectedUserIds.push(user.id) + this.query = '' + }, + removeUser (userId) { + this.selectedUserIds = this.selectedUserIds.filter(id => id !== userId) + }, + search: throttle(function (query) { + if (!query) { + this.loading = false + return + } + + this.loading = true + this.userIds = [] + this.$store.dispatch('search', { q: query, resolve: true, type: 'accounts' }) + .then(data => { + this.loading = false + this.userIds = data.accounts.map(a => a.id) + }) + }) + } +} + +export default chatNew diff --git a/src/components/chat_new/chat_new.scss b/src/components/chat_new/chat_new.scss new file mode 100644 index 00000000..39216677 --- /dev/null +++ b/src/components/chat_new/chat_new.scss @@ -0,0 +1,29 @@ +.chat-new { + .input-wrap { + display: flex; + margin: 0.7em 0.5em 0.7em 0.5em; + + input { + width: 100%; + } + } + + .icon-search { + font-size: 1.5em; + float: right; + margin-right: 0.3em; + } + + .member-list { + padding-bottom: 0.67rem; + } + + .basic-user-card:hover { + cursor: pointer; + background-color: var(--selectedPost, $fallback--lightBg); + } + + .go-back-button { + cursor: pointer; + } +} diff --git a/src/components/chat_new/chat_new.vue b/src/components/chat_new/chat_new.vue new file mode 100644 index 00000000..3333dbf9 --- /dev/null +++ b/src/components/chat_new/chat_new.vue @@ -0,0 +1,46 @@ +<template> + <div + id="nav" + class="panel-default panel chat-new" + > + <div + ref="header" + class="panel-heading" + > + <a + class="go-back-button" + @click="goBack" + > + <i class="button-icon icon-left-open" /> + </a> + </div> + <div class="input-wrap"> + <div class="input-search"> + <i class="button-icon icon-search" /> + </div> + <input + ref="search" + v-model="query" + placeholder="Search people" + @input="onInput" + > + </div> + <div class="member-list"> + <div + v-for="user in availableUsers" + :key="user.id" + class="member" + > + <div @click.capture.prevent="goToChat(user)"> + <BasicUserCard :user="user" /> + </div> + </div> + </div> + </div> +</template> + +<script src="./chat_new.js"></script> +<style lang="scss"> +@import '../../_variables.scss'; +@import './chat_new.scss'; +</style> diff --git a/src/components/chat_panel/chat_panel.vue b/src/components/chat_panel/chat_panel.vue index 3677722f..12968cfb 100644 --- a/src/components/chat_panel/chat_panel.vue +++ b/src/components/chat_panel/chat_panel.vue @@ -84,54 +84,56 @@ max-width: 25em; } -.chat-heading { - cursor: pointer; - .icon-comment-empty { - color: $fallback--text; - color: var(--text, $fallback--text); - } -} - -.chat-window { - overflow-y: auto; - overflow-x: hidden; - max-height: 20em; -} - -.chat-window-container { - height: 100%; -} - -.chat-message { - display: flex; - padding: 0.2em 0.5em -} - -.chat-avatar { - img { - height: 24px; - width: 24px; - border-radius: $fallback--avatarRadius; - border-radius: var(--avatarRadius, $fallback--avatarRadius); - margin-right: 0.5em; - margin-top: 0.25em; - } -} - -.chat-input { - display: flex; - textarea { - flex: 1; - margin: 0.6em; - min-height: 3.5em; - resize: none; - } -} - .chat-panel { - .title { + .chat-heading { + cursor: pointer; + .icon-comment-empty { + color: $fallback--text; + color: var(--text, $fallback--text); + } + } + + .chat-window { + overflow-y: auto; + overflow-x: hidden; + max-height: 20em; + } + + .chat-window-container { + height: 100%; + } + + .chat-message { display: flex; - justify-content: space-between; + padding: 0.2em 0.5em + } + + .chat-avatar { + img { + height: 24px; + width: 24px; + border-radius: $fallback--avatarRadius; + border-radius: var(--avatarRadius, $fallback--avatarRadius); + margin-right: 0.5em; + margin-top: 0.25em; + } + } + + .chat-input { + display: flex; + textarea { + flex: 1; + margin: 0.6em; + min-height: 3.5em; + resize: none; + } + } + + .chat-panel { + .title { + display: flex; + justify-content: space-between; + } } } </style> diff --git a/src/components/chat_title/chat_title.js b/src/components/chat_title/chat_title.js new file mode 100644 index 00000000..2723d5f5 --- /dev/null +++ b/src/components/chat_title/chat_title.js @@ -0,0 +1,20 @@ +import Vue from 'vue' +import ChatAvatar from '../chat_avatar/chat_avatar.vue' + +export default Vue.component('chat-title', { + name: 'ChatTitle', + components: { + ChatAvatar + }, + props: [ + 'user', 'withAvatar' + ], + computed: { + title () { + return this.user ? this.user.screen_name : '' + }, + htmlTitle () { + return this.user ? this.user.name_html : '' + } + } +}) diff --git a/src/components/chat_title/chat_title.vue b/src/components/chat_title/chat_title.vue new file mode 100644 index 00000000..fd42d125 --- /dev/null +++ b/src/components/chat_title/chat_title.vue @@ -0,0 +1,56 @@ +<template> + <!-- eslint-disable vue/no-v-html --> + <div + class="chat-title" + :title="title" + > + <ChatAvatar + v-if="withAvatar" + :user="user" + width="23px" + height="23px" + /> + <span + v-if="withAvatar" + style="margin-right: 0.5em" + /> + <span + class="username" + v-html="htmlTitle" + /> + </div> + <!-- eslint-enable vue/no-v-html --> +</template> + +<script src="./chat_title.js"></script> + +<style lang="scss"> +@import '../../_variables.scss'; + +.chat-title { + display: flex; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + a { + display: flex; + align-items: center; + } + + .username { + max-width: 100%; + text-overflow: ellipsis; + white-space: nowrap; + display: inline; + word-wrap: break-word; + + .emoji { + width: 14px; + height: 14px; + vertical-align: middle; + object-fit: contain + } + } +} +</style> diff --git a/src/components/emoji_input/emoji_input.js b/src/components/emoji_input/emoji_input.js index 7974a66d..a27da090 100644 --- a/src/components/emoji_input/emoji_input.js +++ b/src/components/emoji_input/emoji_input.js @@ -79,6 +79,15 @@ const EmojiInput = { required: false, type: Boolean, default: false + }, + placement: { + /** + * Forces the panel to take a specific position relative to the input element. + * The 'auto' placement chooses either bottom or top depending on which has the available space (when both have available space, bottom is preferred). + */ + required: false, + type: String, // 'auto', 'top', 'bottom' + default: 'auto' } }, data () { @@ -162,6 +171,11 @@ const EmojiInput = { input.elm.removeEventListener('input', this.onInput) } }, + watch: { + showSuggestions: function (newValue) { + this.$emit('shown', newValue) + } + }, methods: { triggerShowPicker () { this.showPicker = true @@ -425,15 +439,29 @@ const EmojiInput = { this.caret = selectionStart }, resize () { - const { panel, picker } = this.$refs + const panel = this.$refs.panel if (!panel) return + const picker = this.$refs.picker.$el + const panelBody = this.$refs['panel-body'] const { offsetHeight, offsetTop } = this.input.elm const offsetBottom = offsetTop + offsetHeight - panel.style.top = offsetBottom + 'px' - if (!picker) return - picker.$el.style.top = offsetBottom + 'px' - picker.$el.style.bottom = 'auto' + this.setPlacement(panelBody, panel, offsetBottom) + this.setPlacement(picker, picker, offsetBottom) + }, + setPlacement (container, target, offsetBottom) { + if (!container || !target) return + + target.style.top = offsetBottom + 'px' + target.style.bottom = 'auto' + + if (this.placement === 'top' || (this.placement === 'auto' && this.overflowsBottom(container))) { + target.style.top = 'auto' + target.style.bottom = this.input.elm.offsetHeight + 'px' + } + }, + overflowsBottom (el) { + return el.getBoundingClientRect().bottom > window.innerHeight } } } diff --git a/src/components/emoji_input/emoji_input.vue b/src/components/emoji_input/emoji_input.vue index e9ac09c3..b9a74572 100644 --- a/src/components/emoji_input/emoji_input.vue +++ b/src/components/emoji_input/emoji_input.vue @@ -29,7 +29,10 @@ class="autocomplete-panel" :class="{ hide: !showSuggestions }" > - <div class="autocomplete-panel-body"> + <div + ref="panel-body" + class="autocomplete-panel-body" + > <div v-for="(suggestion, index) in suggestions" :key="index" diff --git a/src/components/features_panel/features_panel.js b/src/components/features_panel/features_panel.js index 5f80a079..620a85ea 100644 --- a/src/components/features_panel/features_panel.js +++ b/src/components/features_panel/features_panel.js @@ -1,6 +1,7 @@ const FeaturesPanel = { computed: { chat: function () { return this.$store.state.instance.chatAvailable }, + pleromaChatMessages: function () { return this.$store.state.instance.pleromaChatMessagesAvailable }, gopher: function () { return this.$store.state.instance.gopherAvailable }, whoToFollow: function () { return this.$store.state.instance.suggestionsEnabled }, mediaProxy: function () { return this.$store.state.instance.mediaProxyAvailable }, diff --git a/src/components/features_panel/features_panel.vue b/src/components/features_panel/features_panel.vue index 3e5939a6..608b11c8 100644 --- a/src/components/features_panel/features_panel.vue +++ b/src/components/features_panel/features_panel.vue @@ -11,6 +11,9 @@ <li v-if="chat"> {{ $t('features_panel.chat') }} </li> + <li v-if="pleromaChatMessages"> + {{ $t('features_panel.pleroma_chat_messages') }} + </li> <li v-if="gopher"> {{ $t('features_panel.gopher') }} </li> diff --git a/src/components/media_upload/media_upload.js b/src/components/media_upload/media_upload.js index fbb2d03d..7b8a76cc 100644 --- a/src/components/media_upload/media_upload.js +++ b/src/components/media_upload/media_upload.js @@ -61,7 +61,8 @@ const mediaUpload = { } }, props: [ - 'dropFiles' + 'dropFiles', + 'disabled' ], watch: { 'dropFiles': function (fileInfos) { diff --git a/src/components/media_upload/media_upload.vue b/src/components/media_upload/media_upload.vue index 5e31730b..d719eae1 100644 --- a/src/components/media_upload/media_upload.vue +++ b/src/components/media_upload/media_upload.vue @@ -1,5 +1,8 @@ <template> - <div class="media-upload"> + <div + class="media-upload" + :class="{ disabled: disabled }" + > <label class="label" :title="$t('tool_tip.media_upload')" @@ -14,6 +17,7 @@ /> <input v-if="uploadReady" + :disabled="disabled" type="file" style="position: fixed; top: -100em" multiple="true" @@ -26,7 +30,22 @@ <script src="./media_upload.js" ></script> <style lang="scss"> +@import '../../_variables.scss'; + .media-upload { + &.disabled { + .new-icon { + cursor: not-allowed; + } + + &:hover { + i, label { + color: $fallback--faint; + color: var(--faint, $fallback--faint); + } + } + } + .label { display: inline-block; } diff --git a/src/components/mobile_nav/mobile_nav.js b/src/components/mobile_nav/mobile_nav.js index c1166a0c..b27ca6f4 100644 --- a/src/components/mobile_nav/mobile_nav.js +++ b/src/components/mobile_nav/mobile_nav.js @@ -30,7 +30,10 @@ const MobileNav = { return this.unseenNotifications.length }, hideSitename () { return this.$store.state.instance.hideSitename }, - sitename () { return this.$store.state.instance.name } + sitename () { return this.$store.state.instance.name }, + navBarStyle () { + return { 'visibility': this.$route.name === 'chat' ? 'hidden' : 'visible' } + } }, methods: { toggleMobileSidebar () { diff --git a/src/components/mobile_nav/mobile_nav.vue b/src/components/mobile_nav/mobile_nav.vue index 51f1d636..05568b90 100644 --- a/src/components/mobile_nav/mobile_nav.vue +++ b/src/components/mobile_nav/mobile_nav.vue @@ -3,6 +3,7 @@ <nav id="nav" class="nav-bar container" + :style="navBarStyle" > <div class="mobile-inner-nav" diff --git a/src/components/mobile_post_status_button/mobile_post_status_button.js b/src/components/mobile_post_status_button/mobile_post_status_button.js index 0ad12bb1..6348277b 100644 --- a/src/components/mobile_post_status_button/mobile_post_status_button.js +++ b/src/components/mobile_post_status_button/mobile_post_status_button.js @@ -1,5 +1,10 @@ import { debounce } from 'lodash' +const HIDDEN_FOR_PAGES = new Set([ + 'chats', + 'chat' +]) + const MobilePostStatusButton = { data () { return { @@ -27,6 +32,8 @@ const MobilePostStatusButton = { return !!this.$store.state.users.currentUser }, isHidden () { + if (HIDDEN_FOR_PAGES.has(this.$route.name)) { return true } + return this.autohideFloatingPostButton && (this.hidden || this.inputActive) }, autohideFloatingPostButton () { diff --git a/src/components/nav_panel/nav_panel.js b/src/components/nav_panel/nav_panel.js index 8f7edb7f..b142ffe0 100644 --- a/src/components/nav_panel/nav_panel.js +++ b/src/components/nav_panel/nav_panel.js @@ -1,4 +1,4 @@ -import { mapState } from 'vuex' +import { mapState, mapGetters } from 'vuex' const NavPanel = { created () { @@ -6,13 +6,17 @@ const NavPanel = { this.$store.dispatch('startFetchingFollowRequests') } }, - computed: mapState({ - currentUser: state => state.users.currentUser, - chat: state => state.chat.channel, - followRequestCount: state => state.api.followRequests.length, - privateMode: state => state.instance.private, - federating: state => state.instance.federating - }) + computed: { + ...mapState({ + currentUser: state => state.users.currentUser, + chat: state => state.chat.channel, + followRequestCount: state => state.api.followRequests.length, + privateMode: state => state.instance.private, + federating: state => state.instance.federating, + pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable + }), + ...mapGetters(['unreadChatCount']) + } } export default NavPanel diff --git a/src/components/nav_panel/nav_panel.vue b/src/components/nav_panel/nav_panel.vue index f164b2b0..8a213d7e 100644 --- a/src/components/nav_panel/nav_panel.vue +++ b/src/components/nav_panel/nav_panel.vue @@ -22,6 +22,17 @@ <i class="button-icon icon-bookmark" /> {{ $t("nav.bookmarks") }} </router-link> </li> + <li v-if="currentUser && pleromaChatMessagesAvailable"> + <router-link :to="{ name: 'chats', params: { username: currentUser.screen_name } }"> + <div + v-if="unreadChatCount" + class="badge badge-notification unread-chat-count" + > + {{ unreadChatCount }} + </div> + <i class="button-icon icon-chat" /> {{ $t("nav.chats") }} + </router-link> + </li> <li v-if="currentUser && currentUser.locked"> <router-link :to="{ name: 'friend-requests' }"> <i class="button-icon icon-user-plus" /> {{ $t("nav.friend_requests") }} diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js index 5aa40e98..bb906b50 100644 --- a/src/components/notification/notification.js +++ b/src/components/notification/notification.js @@ -1,4 +1,5 @@ import StatusContent from '../status_content/status_content.vue' +import { mapState } from 'vuex' import Status from '../status/status.vue' import UserAvatar from '../user_avatar/user_avatar.vue' import UserCard from '../user_card/user_card.vue' @@ -81,7 +82,10 @@ const Notification = { }, isStatusNotification () { return isStatusNotification(this.notification.type) - } + }, + ...mapState({ + currentUser: state => state.users.currentUser + }) } } diff --git a/src/components/notifications/notifications.js b/src/components/notifications/notifications.js index d8a327b0..d951e2a8 100644 --- a/src/components/notifications/notifications.js +++ b/src/components/notifications/notifications.js @@ -1,3 +1,4 @@ +import { mapGetters } from 'vuex' import Notification from '../notification/notification.vue' import notificationsFetcher from '../../services/notifications_fetcher/notifications_fetcher.service.js' import { @@ -51,18 +52,22 @@ const Notifications = { unseenCount () { return this.unseenNotifications.length }, + unseenCountTitle () { + return this.unseenCount + (this.unreadChatCount) + }, loading () { return this.$store.state.statuses.notifications.loading }, notificationsToDisplay () { return this.filteredNotifications.slice(0, this.unseenCount + this.seenToDisplayCount) - } + }, + ...mapGetters(['unreadChatCount']) }, components: { Notification }, watch: { - unseenCount (count) { + unseenCountTitle (count) { if (count > 0) { this.$store.dispatch('setPageTitle', `(${count})`) } else { diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js index 18f02eba..90d0fa81 100644 --- a/src/components/post_status_form/post_status_form.js +++ b/src/components/post_status_form/post_status_form.js @@ -9,7 +9,7 @@ import fileTypeService from '../../services/file_type/file_type.service.js' import { findOffset } from '../../services/offset_finder/offset_finder.service.js' import { reject, map, uniqBy, debounce } from 'lodash' import suggestor from '../emoji_input/suggestor.js' -import { mapGetters } from 'vuex' +import { mapGetters, mapState } from 'vuex' import Checkbox from '../checkbox/checkbox.vue' const buildMentionsString = ({ user, attentions = [] }, currentUser) => { @@ -33,7 +33,22 @@ const PostStatusForm = { 'repliedUser', 'attentions', 'copyMessageScope', - 'subject' + 'subject', + 'disableSubject', + 'disableScopeSelector', + 'disableNotice', + 'disableLockWarning', + 'disablePolls', + 'disableSensitivityCheckbox', + 'disableSubmit', + 'placeholder', + 'maxHeight', + 'request', + 'preserveFocus', + 'autoFocus', + 'fileLimit', + 'submitOnEnter', + 'emojiPickerPlacement' ], components: { MediaUpload, @@ -46,10 +61,13 @@ const PostStatusForm = { }, mounted () { this.resize(this.$refs.textarea) - const textLength = this.$refs.textarea.value.length - this.$refs.textarea.setSelectionRange(textLength, textLength) if (this.replyTo) { + const textLength = this.$refs.textarea.value.length + this.$refs.textarea.setSelectionRange(textLength, textLength) + } + + if (this.replyTo || this.autoFocus) { this.$refs.textarea.focus() } }, @@ -72,7 +90,7 @@ const PostStatusForm = { return { dropFiles: [], - submitDisabled: false, + uploadingFiles: false, error: null, posting: false, highlighted: 0, @@ -91,7 +109,8 @@ const PostStatusForm = { showDropIcon: 'hide', dropStopTimeout: null, preview: null, - previewLoading: false + previewLoading: false, + emojiInputShown: false } }, computed: { @@ -160,10 +179,11 @@ const PostStatusForm = { }, pollsAvailable () { return this.$store.state.instance.pollsAvailable && - this.$store.state.instance.pollLimits.max_options >= 2 + this.$store.state.instance.pollLimits.max_options >= 2 && + this.disablePolls !== true }, hideScopeNotice () { - return this.$store.getters.mergedConfig.hideScopeNotice + return this.disableNotice || this.$store.getters.mergedConfig.hideScopeNotice }, pollContentError () { return this.pollFormVisible && @@ -176,7 +196,13 @@ const PostStatusForm = { emptyStatus () { return this.newStatus.status.trim() === '' && this.newStatus.files.length === 0 }, - ...mapGetters(['mergedConfig']) + uploadFileLimitReached () { + return this.newStatus.files.length >= this.fileLimit + }, + ...mapGetters(['mergedConfig']), + ...mapState({ + mobileLayout: state => state.interface.mobileLayout + }) }, watch: { 'newStatus.contentType': function () { @@ -187,9 +213,19 @@ const PostStatusForm = { } }, methods: { - async postStatus (newStatus) { + async postStatus (event, newStatus, opts = {}) { if (this.posting) { return } if (this.submitDisabled) { return } + if (this.emojiInputShown) { return } + if (this.submitOnEnter) { + event.stopPropagation() + event.preventDefault() + } + if (opts.control && this.submitOnEnter) { + newStatus.status = `${newStatus.status}\n` + return + } + if (this.emptyStatus) { this.error = this.$t('post_status.empty_status_error') return @@ -211,7 +247,7 @@ const PostStatusForm = { return } - const data = await statusPoster.postStatus({ + const postingOptions = { status: newStatus.status, spoilerText: newStatus.spoilerText || null, visibility: newStatus.visibility, @@ -221,32 +257,40 @@ const PostStatusForm = { inReplyToStatusId: this.replyTo, contentType: newStatus.contentType, poll - }) - - if (!data.error) { - this.newStatus = { - status: '', - spoilerText: '', - files: [], - visibility: newStatus.visibility, - contentType: newStatus.contentType, - poll: {}, - mediaDescriptions: {} - } - this.pollFormVisible = false - this.$refs.mediaUpload.clearFile() - this.clearPollForm() - this.$emit('posted') - let el = this.$el.querySelector('textarea') - el.style.height = 'auto' - el.style.height = undefined - this.error = null - if (this.preview) this.previewStatus() - } else { - this.error = data.error } - this.posting = false + const request = this.request ? this.request : statusPoster.postStatus + + request(postingOptions).then((data) => { + if (!data.error) { + this.newStatus = { + status: '', + spoilerText: '', + files: [], + visibility: newStatus.visibility, + contentType: newStatus.contentType, + poll: {}, + mediaDescriptions: {} + } + this.pollFormVisible = false + this.$refs.mediaUpload && this.$refs.mediaUpload.clearFile() + this.clearPollForm() + this.$emit('posted', data) + if (this.preserveFocus) { + this.$nextTick(() => { + this.$refs.textarea.focus() + }) + } + let el = this.$el.querySelector('textarea') + el.style.height = 'auto' + el.style.height = undefined + this.error = null + if (this.preview) this.previewStatus() + } else { + this.error = data.error + } + this.posting = false + }) }, previewStatus () { if (this.emptyStatus && this.newStatus.spoilerText.trim() === '') { @@ -301,20 +345,26 @@ const PostStatusForm = { }, addMediaFile (fileInfo) { this.newStatus.files.push(fileInfo) + + // TODO: use fixed dimensions instead so relying on timeout + setTimeout(() => { + this.$emit('resize') + }, 150) }, removeMediaFile (fileInfo) { let index = this.newStatus.files.indexOf(fileInfo) this.newStatus.files.splice(index, 1) + this.$emit('resize') }, uploadFailed (errString, templateArgs) { templateArgs = templateArgs || {} this.error = this.$t('upload.error.base') + ' ' + this.$t('upload.error.' + errString, templateArgs) }, - disableSubmit () { - this.submitDisabled = true + startedUploadingFiles () { + this.uploadingFiles = true }, - enableSubmit () { - this.submitDisabled = false + finishedUploadingFiles () { + this.uploadingFiles = false }, type (fileInfo) { return fileTypeService.fileType(fileInfo.mimetype) @@ -348,7 +398,7 @@ const PostStatusForm = { this.dropStopTimeout = setTimeout(() => (this.showDropIcon = 'hide'), 500) }, fileDrag (e) { - e.dataTransfer.dropEffect = 'copy' + e.dataTransfer.dropEffect = this.uploadFileLimitReached ? 'none' : 'copy' if (e.dataTransfer && e.dataTransfer.types.includes('Files')) { clearTimeout(this.dropStopTimeout) this.showDropIcon = 'show' @@ -367,6 +417,7 @@ const PostStatusForm = { // Reset to default height for empty form, nothing else to do here. if (target.value === '') { target.style.height = null + this.$emit('resize', null) this.$refs['emoji-input'].resize() return } @@ -419,8 +470,10 @@ const PostStatusForm = { // BEGIN content size update target.style.height = 'auto' - const newHeight = target.scrollHeight - vertPadding + const heightWithoutPadding = target.scrollHeight - vertPadding + const newHeight = this.maxHeight ? Math.min(heightWithoutPadding, this.maxHeight) : heightWithoutPadding target.style.height = `${newHeight}px` + this.$emit('resize', newHeight) // END content size update // We check where the bottom border of form-bottom element is, this uses findOffset @@ -480,6 +533,9 @@ const PostStatusForm = { setAllMediaDescriptions () { const ids = this.newStatus.files.map(file => file.id) return Promise.all(ids.map(id => this.setMediaDescription(id))) + }, + handleEmojiInputShow (value) { + this.emojiInputShown = value } } } diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue index 626584ed..d8df68d6 100644 --- a/src/components/post_status_form/post_status_form.vue +++ b/src/components/post_status_form/post_status_form.vue @@ -5,19 +5,20 @@ > <form autocomplete="off" - @submit.prevent="postStatus(newStatus)" + @submit.prevent @dragover.prevent="fileDrag" > <div v-show="showDropIcon !== 'hide'" :style="{ animation: showDropIcon === 'show' ? 'fade-in 0.25s' : 'fade-out 0.5s' }" - class="drop-indicator icon-upload" + class="drop-indicator" + :class="[uploadFileLimitReached ? 'icon-block' : 'icon-upload']" @dragleave="fileDragStop" @drop.stop="fileDrop" /> <div class="form-group"> <i18n - v-if="!$store.state.users.currentUser.locked && newStatus.visibility == 'private'" + v-if="!$store.state.users.currentUser.locked && newStatus.visibility == 'private' && !disableLockWarning" path="post_status.account_not_locked_warning" tag="p" class="visibility-notice" @@ -108,7 +109,7 @@ /> </div> <EmojiInput - v-if="newStatus.spoilerText || alwaysShowSubject" + v-if="!disableSubject && (newStatus.spoilerText || alwaysShowSubject)" v-model="newStatus.spoilerText" enable-emoji-picker :suggest="emojiSuggestor" @@ -126,6 +127,7 @@ ref="emoji-input" v-model="newStatus.status" :suggest="emojiUserSuggestor" + :placement="emojiPickerPlacement" class="form-control main-input" enable-emoji-picker hide-emoji-button @@ -133,16 +135,19 @@ @input="onEmojiInputInput" @sticker-uploaded="addMediaFile" @sticker-upload-failed="uploadFailed" + @shown="handleEmojiInputShow" > <textarea ref="textarea" v-model="newStatus.status" - :placeholder="$t('post_status.default')" + :placeholder="placeholder || $t('post_status.default')" rows="1" :disabled="posting" class="form-post-body" - @keydown.meta.enter="postStatus(newStatus)" - @keydown.ctrl.enter="postStatus(newStatus)" + :class="{ 'scrollable-form': !!maxHeight }" + @keydown.exact.enter="submitOnEnter && postStatus($event, newStatus)" + @keydown.meta.enter="postStatus($event, newStatus, { control: true })" + @keydown.ctrl.enter="postStatus($event, newStatus)" @input="resize" @compositionupdate="resize" @paste="paste" @@ -155,7 +160,10 @@ {{ charactersLeft }} </p> </EmojiInput> - <div class="visibility-tray"> + <div + v-if="!disableScopeSelector" + class="visibility-tray" + > <scope-selector :show-all="showAllScopes" :user-default="userDefaultScope" @@ -213,10 +221,11 @@ ref="mediaUpload" class="media-upload-icon" :drop-files="dropFiles" - @uploading="disableSubmit" + :disabled="uploadFileLimitReached" + @uploading="startedUploadingFiles" @uploaded="addMediaFile" @upload-failed="uploadFailed" - @all-uploaded="enableSubmit" + @all-uploaded="finishedUploadingFiles" /> <div class="emoji-icon" @@ -253,11 +262,13 @@ > {{ $t('general.submit') }} </button> + <!-- touchstart is used to keep the OSK at the same position after a message send --> <button v-else - :disabled="submitDisabled" - type="submit" + :disabled="uploadingFiles || disableSubmit" class="btn btn-default" + @touchstart.stop.prevent="postStatus($event, newStatus)" + @click.stop.prevent="postStatus($event, newStatus)" > {{ $t('general.submit') }} </button> @@ -297,7 +308,7 @@ </div> </div> <div - v-if="newStatus.files.length > 0" + v-if="newStatus.files.length > 0 && !disableSensitivityCheckbox" class="upload_settings" > <Checkbox v-model="newStatus.nsfw"> @@ -331,6 +342,8 @@ } .post-status-form { + position: relative; + .form-bottom { display: flex; justify-content: space-between; @@ -547,6 +560,10 @@ padding-bottom: 1.75em; min-height: 1px; box-sizing: content-box; + + &.scrollable-form { + overflow-y: auto; + } } .main-input { @@ -609,4 +626,11 @@ border: 2px dashed var(--text, $fallback--text); } } + +// todo: unify with attachment.vue (otherwise the uploaded images are not minified unless a status with an attachment was displayed before) +img.media-upload { + line-height: 0; + max-height: 200px; + max-width: 100%; +} </style> diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.js b/src/components/settings_modal/tabs/theme_tab/theme_tab.js index 9d61b0c4..e3c5e80a 100644 --- a/src/components/settings_modal/tabs/theme_tab/theme_tab.js +++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.js @@ -99,7 +99,8 @@ export default { avatarRadiusLocal: '', avatarAltRadiusLocal: '', attachmentRadiusLocal: '', - tooltipRadiusLocal: '' + tooltipRadiusLocal: '', + chatMessageRadiusLocal: '' } }, created () { @@ -214,7 +215,8 @@ export default { avatar: this.avatarRadiusLocal, avatarAlt: this.avatarAltRadiusLocal, tooltip: this.tooltipRadiusLocal, - attachment: this.attachmentRadiusLocal + attachment: this.attachmentRadiusLocal, + chatMessage: this.chatMessageRadiusLocal } }, preview () { diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.vue b/src/components/settings_modal/tabs/theme_tab/theme_tab.vue index d14f854c..d57894de 100644 --- a/src/components/settings_modal/tabs/theme_tab/theme_tab.vue +++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.vue @@ -735,6 +735,65 @@ /> <ContrastRatio :contrast="previewContrast.selectedMenuLink" /> </div> + <div class="color-item"> + <h4>{{ $t('chats.chats') }}</h4> + <ColorInput + v-model="chatBgColorLocal" + name="chatBgColor" + :fallback="previewTheme.colors.bg || 1" + :label="$t('settings.background')" + /> + <h5>{{ $t('settings.style.advanced_colors.chat.incoming') }}</h5> + <ColorInput + v-model="chatMessageIncomingBgColorLocal" + name="chatMessageIncomingBgColor" + :fallback="previewTheme.colors.bg || 1" + :label="$t('settings.background')" + /> + <ColorInput + v-model="chatMessageIncomingTextColorLocal" + name="chatMessageIncomingTextColor" + :fallback="previewTheme.colors.text || 1" + :label="$t('settings.text')" + /> + <ColorInput + v-model="chatMessageIncomingLinkColorLocal" + name="chatMessageIncomingLinkColor" + :fallback="previewTheme.colors.link || 1" + :label="$t('settings.links')" + /> + <ColorInput + v-model="chatMessageIncomingBorderColorLocal" + name="chatMessageIncomingBorderLinkColor" + :fallback="previewTheme.colors.fg || 1" + :label="$t('settings.style.advanced_colors.chat.border')" + /> + <h5>{{ $t('settings.style.advanced_colors.chat.outgoing') }}</h5> + <ColorInput + v-model="chatMessageOutgoingBgColorLocal" + name="chatMessageOutgoingBgColor" + :fallback="previewTheme.colors.bg || 1" + :label="$t('settings.background')" + /> + <ColorInput + v-model="chatMessageOutgoingTextColorLocal" + name="chatMessageOutgoingTextColor" + :fallback="previewTheme.colors.text || 1" + :label="$t('settings.text')" + /> + <ColorInput + v-model="chatMessageOutgoingLinkColorLocal" + name="chatMessageOutgoingLinkColor" + :fallback="previewTheme.colors.link || 1" + :label="$t('settings.links')" + /> + <ColorInput + v-model="chatMessageOutgoingBorderColorLocal" + name="chatMessageOutgoingBorderLinkColor" + :fallback="previewTheme.colors.bg || 1" + :label="$t('settings.style.advanced_colors.chat.border')" + /> + </div> </div> <div @@ -814,6 +873,14 @@ max="50" hard-min="0" /> + <RangeInput + v-model="chatMessageRadiusLocal" + name="chatMessageRadius" + :label="$t('settings.chatMessageRadius')" + :fallback="previewTheme.radii.chatMessage || 2" + max="50" + hard-min="0" + /> </div> <div diff --git a/src/components/side_drawer/side_drawer.js b/src/components/side_drawer/side_drawer.js index d1f044f6..3a9e9e8f 100644 --- a/src/components/side_drawer/side_drawer.js +++ b/src/components/side_drawer/side_drawer.js @@ -1,3 +1,4 @@ +import { mapState, mapGetters } from 'vuex' import UserCard from '../user_card/user_card.vue' import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils' import GestureService from '../../services/gesture_service/gesture_service' @@ -47,7 +48,11 @@ const SideDrawer = { }, federating () { return this.$store.state.instance.federating - } + }, + ...mapState({ + pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable + }), + ...mapGetters(['unreadChatCount']) }, methods: { toggleDrawer () { diff --git a/src/components/side_drawer/side_drawer.vue b/src/components/side_drawer/side_drawer.vue index 0ac53b34..4fdb3d13 100644 --- a/src/components/side_drawer/side_drawer.vue +++ b/src/components/side_drawer/side_drawer.vue @@ -40,12 +40,24 @@ </router-link> </li> <li - v-if="currentUser" + v-if="currentUser && pleromaChatMessagesAvailable" @click="toggleDrawer" > <router-link :to="{ name: 'dms', params: { username: currentUser.screen_name } }"> <i class="button-icon icon-mail-alt" /> {{ $t("nav.dms") }} </router-link> + <router-link + :to="{ name: 'chats', params: { username: currentUser.screen_name } }" + style="position: relative" + > + <i class="button-icon icon-chat" /> {{ $t("nav.chats") }} + <span + v-if="unreadChatCount" + class="badge badge-notification unread-chat-count" + > + {{ unreadChatCount }} + </span> + </router-link> </li> <li v-if="currentUser" @@ -103,14 +115,6 @@ <i class="button-icon icon-globe" /> {{ $t("nav.twkn") }} </router-link> </li> - <li - v-if="currentUser && chat" - @click="toggleDrawer" - > - <router-link :to="{ name: 'chat' }"> - <i class="button-icon icon-chat" /> {{ $t("nav.chat") }} - </router-link> - </li> </ul> <ul> <li diff --git a/src/components/status_content/status_content.js b/src/components/status_content/status_content.js index 67d9bd3c..dc83d8cb 100644 --- a/src/components/status_content/status_content.js +++ b/src/components/status_content/status_content.js @@ -18,7 +18,7 @@ const StatusContent = { ], data () { return { - showingTall: this.inConversation && this.focused, + showingTall: this.fullContent || (this.inConversation && this.focused), showingLongSubject: false, // not as computed because it sets the initial state which will be changed later expandingSubject: !this.$store.getters.mergedConfig.collapseMessageWithSubject diff --git a/src/components/status_content/status_content.vue b/src/components/status_content/status_content.vue index 8068d8d2..9ca2cf6d 100644 --- a/src/components/status_content/status_content.vue +++ b/src/components/status_content/status_content.vue @@ -76,7 +76,7 @@ /> </a> <a - v-if="showingMore" + v-if="showingMore && !fullContent" href="#" class="status-unhider" @click.prevent="toggleShowMore" diff --git a/src/hocs/with_load_more/with_load_more.scss b/src/hocs/with_load_more/with_load_more.scss index 4cefe2be..1a26eb8d 100644 --- a/src/hocs/with_load_more/with_load_more.scss +++ b/src/hocs/with_load_more/with_load_more.scss @@ -12,5 +12,9 @@ .error { font-size: 14px; } + + a { + cursor: pointer; + } } } diff --git a/src/i18n/en.json b/src/i18n/en.json index 72c3b1f7..c9a34556 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -44,6 +44,7 @@ }, "features_panel": { "chat": "Chat", + "pleroma_chat_messages": "Pleroma Chat", "gopher": "Gopher", "media_proxy": "Media proxy", "scope_options": "Scope options", @@ -124,7 +125,8 @@ "user_search": "User Search", "search": "Search", "who_to_follow": "Who to follow", - "preferences": "Preferences" + "preferences": "Preferences", + "chats": "Chats" }, "notifications": { "broken_favorite": "Unknown status, searching for it…", @@ -287,6 +289,7 @@ "change_password": "Change Password", "change_password_error": "There was an issue changing your password.", "changed_password": "Password changed successfully!", + "chatMessageRadius": "Chat message", "collapse_subject": "Collapse posts with subjects", "composing": "Composing", "confirm_new_password": "Confirm new password", @@ -518,7 +521,12 @@ "selectedMenu": "Selected menu item", "disabled": "Disabled", "toggled": "Toggled", - "tabs": "Tabs" + "tabs": "Tabs", + "chat": { + "incoming": "Incoming", + "outgoing": "Outgoing", + "border": "Border" + } }, "radii": { "_tab_label": "Roundness" @@ -677,6 +685,7 @@ "its_you": "It's you!", "media": "Media", "mention": "Mention", + "message": "Message", "mute": "Mute", "muted": "Muted", "per_day": "per day", @@ -775,5 +784,25 @@ "password_reset_disabled": "Password reset is disabled. Please contact your instance administrator.", "password_reset_required": "You must reset your password to log in.", "password_reset_required_but_mailer_is_disabled": "You must reset your password, but password reset is disabled. Please contact your instance administrator." + }, + "chats": { + "message_user": "Message {nickname}", + "delete": "Delete", + "chats": "Chats", + "new": "New Chat", + "empty_message_error": "Cannot post empty message", + "more": "More", + "delete_confirm": "Do you really want to delete this message?", + "error_loading_chat": "Something went wrong when loading the chat.", + "error_sending_message": "Something went wrong when sending the message." + }, + "file_type": { + "audio": "Audio", + "video": "Video", + "image": "Image", + "file": "File" + }, + "display_date": { + "today": "Today" } } diff --git a/src/main.js b/src/main.js index 5bddc76e..0a898022 100644 --- a/src/main.js +++ b/src/main.js @@ -19,6 +19,7 @@ import oauthTokensModule from './modules/oauth_tokens.js' import reportsModule from './modules/reports.js' import pollsModule from './modules/polls.js' import postStatusModule from './modules/postStatus.js' +import chatsModule from './modules/chats.js' import VueI18n from 'vue-i18n' @@ -91,7 +92,8 @@ const persistedStateOptions = { oauthTokens: oauthTokensModule, reports: reportsModule, polls: pollsModule, - postStatus: postStatusModule + postStatus: postStatusModule, + chats: chatsModule }, plugins, strict: false // Socket modifies itself, let's ignore this for now. diff --git a/src/modules/api.js b/src/modules/api.js index 04ef6ab4..68402602 100644 --- a/src/modules/api.js +++ b/src/modules/api.js @@ -1,4 +1,5 @@ import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js' +import { WSConnectionStatus } from '../services/api/api.service.js' import { Socket } from 'phoenix' const api = { @@ -7,6 +8,7 @@ const api = { fetchers: {}, socket: null, mastoUserSocket: null, + mastoUserSocketStatus: null, followRequests: [] }, mutations: { @@ -28,6 +30,9 @@ const api = { }, setFollowRequests (state, value) { state.followRequests = value + }, + setMastoUserSocketStatus (state, value) { + state.mastoUserSocketStatus = value } }, actions: { @@ -47,7 +52,7 @@ const api = { startMastoUserSocket (store) { return new Promise((resolve, reject) => { try { - const { state, dispatch, rootState } = store + const { state, commit, dispatch, rootState } = store const timelineData = rootState.statuses.timelines.friends state.mastoUserSocket = state.backendInteractor.startUserSocket({ store }) state.mastoUserSocket.addEventListener( @@ -66,11 +71,22 @@ const api = { showImmediately: timelineData.visibleStatuses.length === 0, timeline: 'friends' }) + } else if (message.event === 'pleroma:chat_update') { + dispatch('addChatMessages', { + chatId: message.chatUpdate.id, + messages: [message.chatUpdate.lastMessage] + }) + dispatch('updateChat', { chat: message.chatUpdate }) } } ) + state.mastoUserSocket.addEventListener('open', () => { + commit('setMastoUserSocketStatus', WSConnectionStatus.JOINED) + }) state.mastoUserSocket.addEventListener('error', ({ detail: error }) => { console.error('Error in MastoAPI websocket:', error) + commit('setMastoUserSocketStatus', WSConnectionStatus.ERROR) + dispatch('clearOpenedChats') }) state.mastoUserSocket.addEventListener('close', ({ detail: closeEvent }) => { const ignoreCodes = new Set([ @@ -84,8 +100,11 @@ const api = { console.warn(`MastoAPI websocket disconnected, restarting. CloseEvent code: ${code}`) dispatch('startFetchingTimeline', { timeline: 'friends' }) dispatch('startFetchingNotifications') + dispatch('startFetchingChats') dispatch('restartMastoUserSocket') } + commit('setMastoUserSocketStatus', WSConnectionStatus.CLOSED) + dispatch('clearOpenedChats') }) resolve() } catch (e) { @@ -99,12 +118,13 @@ const api = { return dispatch('startMastoUserSocket').then(() => { dispatch('stopFetchingTimeline', { timeline: 'friends' }) dispatch('stopFetchingNotifications') + dispatch('stopFetchingChats') }) }, stopMastoUserSocket ({ state, dispatch }) { dispatch('startFetchingTimeline', { timeline: 'friends' }) dispatch('startFetchingNotifications') - console.log(state.mastoUserSocket) + dispatch('startFetchingChats') state.mastoUserSocket.close() }, diff --git a/src/modules/chats.js b/src/modules/chats.js new file mode 100644 index 00000000..f868ca0c --- /dev/null +++ b/src/modules/chats.js @@ -0,0 +1,228 @@ +import Vue from 'vue' +import { find, omitBy, orderBy, sumBy } from 'lodash' +import chatService from '../services/chat_service/chat_service.js' +import { parseChat, parseChatMessage } from '../services/entity_normalizer/entity_normalizer.service.js' + +const emptyChatList = () => ({ + data: [], + idStore: {} +}) + +const defaultState = { + chatList: emptyChatList(), + chatListFetcher: null, + openedChats: {}, + openedChatMessageServices: {}, + fetcher: undefined, + currentChatId: null +} + +const getChatById = (state, id) => { + return find(state.chatList.data, { id }) +} + +const sortedChatList = (state) => { + return orderBy(state.chatList.data, ['updated_at'], ['desc']) +} + +const unreadChatCount = (state) => { + return sumBy(state.chatList.data, 'unread') +} + +const chats = { + state: { ...defaultState }, + getters: { + currentChat: state => state.openedChats[state.currentChatId], + currentChatMessageService: state => state.openedChatMessageServices[state.currentChatId], + findOpenedChatByRecipientId: state => recipientId => find(state.openedChats, c => c.account.id === recipientId), + sortedChatList, + unreadChatCount + }, + actions: { + // Chat list + startFetchingChats ({ dispatch, commit }) { + const fetcher = () => { + dispatch('fetchChats', { latest: true }) + } + fetcher() + commit('setChatListFetcher', { + fetcher: () => setInterval(() => { fetcher() }, 5000) + }) + }, + stopFetchingChats ({ commit }) { + commit('setChatListFetcher', { fetcher: undefined }) + }, + fetchChats ({ dispatch, rootState, commit }, params = {}) { + return rootState.api.backendInteractor.chats() + .then(({ chats }) => { + dispatch('addNewChats', { chats }) + return chats + }) + }, + addNewChats ({ rootState, commit, dispatch, rootGetters }, { chats }) { + commit('addNewChats', { dispatch, chats, rootGetters }) + }, + updateChat ({ commit }, { chat }) { + commit('updateChat', { chat }) + }, + + // Opened Chats + startFetchingCurrentChat ({ commit, dispatch }, { fetcher }) { + dispatch('setCurrentChatFetcher', { fetcher }) + }, + setCurrentChatFetcher ({ rootState, commit }, { fetcher }) { + commit('setCurrentChatFetcher', { fetcher }) + }, + addOpenedChat ({ rootState, commit, dispatch }, { chat }) { + commit('addOpenedChat', { dispatch, chat: parseChat(chat) }) + dispatch('addNewUsers', [chat.account]) + }, + addChatMessages ({ commit }, value) { + commit('addChatMessages', { commit, ...value }) + }, + resetChatNewMessageCount ({ commit }, value) { + commit('resetChatNewMessageCount', value) + }, + removeFromCurrentChatStatuses ({ commit }, { id }) { + commit('removeFromCurrentChatStatuses', id) + }, + clearCurrentChat ({ rootState, commit, dispatch }, value) { + commit('setCurrentChatId', { chatId: undefined }) + commit('setCurrentChatFetcher', { fetcher: undefined }) + }, + readChat ({ rootState, commit, dispatch }, { id, lastReadId }) { + dispatch('resetChatNewMessageCount') + commit('readChat', { id }) + rootState.api.backendInteractor.readChat({ id, lastReadId }) + }, + deleteChatMessage ({ rootState, commit }, value) { + rootState.api.backendInteractor.deleteChatMessage(value) + commit('deleteChatMessage', { commit, ...value }) + }, + resetChats ({ commit, dispatch }) { + dispatch('clearCurrentChat') + commit('resetChats', { commit }) + }, + clearOpenedChats ({ rootState, commit, dispatch, rootGetters }) { + commit('clearOpenedChats', { commit }) + } + }, + mutations: { + setChatListFetcher (state, { commit, fetcher }) { + const prevFetcher = state.chatListFetcher + if (prevFetcher) { + clearInterval(prevFetcher) + } + state.chatListFetcher = fetcher && fetcher() + }, + setCurrentChatFetcher (state, { fetcher }) { + const prevFetcher = state.fetcher + if (prevFetcher) { + clearInterval(prevFetcher) + } + state.fetcher = fetcher && fetcher() + }, + addOpenedChat (state, { _dispatch, chat }) { + state.currentChatId = chat.id + Vue.set(state.openedChats, chat.id, chat) + + if (!state.openedChatMessageServices[chat.id]) { + Vue.set(state.openedChatMessageServices, chat.id, chatService.empty(chat.id)) + } + }, + setCurrentChatId (state, { chatId }) { + state.currentChatId = chatId + }, + addNewChats (state, { _dispatch, chats, _rootGetters }) { + chats.forEach((updatedChat) => { + const chat = getChatById(state, updatedChat.id) + + if (chat) { + chat.lastMessage = updatedChat.lastMessage + chat.unread = updatedChat.unread + } else { + state.chatList.data.push(updatedChat) + Vue.set(state.chatList.idStore, updatedChat.id, updatedChat) + } + }) + }, + updateChat (state, { _dispatch, chat: updatedChat, _rootGetters }) { + const chat = getChatById(state, updatedChat.id) + if (chat) { + chat.lastMessage = updatedChat.lastMessage + chat.unread = updatedChat.unread + chat.updated_at = updatedChat.updated_at + } + if (!chat) { state.chatList.data.unshift(updatedChat) } + Vue.set(state.chatList.idStore, updatedChat.id, updatedChat) + }, + deleteChat (state, { _dispatch, id, _rootGetters }) { + state.chats.data = state.chats.data.filter(conversation => + conversation.last_status.id !== id + ) + state.chats.idStore = omitBy(state.chats.idStore, conversation => conversation.last_status.id === id) + }, + resetChats (state, { commit }) { + state.chatList = emptyChatList() + state.currentChatId = null + commit('setChatListFetcher', { fetcher: undefined }) + for (const chatId in state.openedChats) { + chatService.clear(state.openedChatMessageServices[chatId]) + Vue.delete(state.openedChats, chatId) + Vue.delete(state.openedChatMessageServices, chatId) + } + }, + setChatsLoading (state, { value }) { + state.chats.loading = value + }, + addChatMessages (state, { commit, chatId, messages }) { + const chatMessageService = state.openedChatMessageServices[chatId] + if (chatMessageService) { + chatService.add(chatMessageService, { messages: messages.map(parseChatMessage) }) + commit('refreshLastMessage', { chatId }) + } + }, + refreshLastMessage (state, { chatId }) { + const chatMessageService = state.openedChatMessageServices[chatId] + if (chatMessageService) { + const chat = getChatById(state, chatId) + if (chat) { + chat.lastMessage = chatMessageService.lastMessage + if (chatMessageService.lastMessage) { + chat.updated_at = chatMessageService.lastMessage.created_at + } + } + } + }, + deleteChatMessage (state, { commit, chatId, messageId }) { + const chatMessageService = state.openedChatMessageServices[chatId] + if (chatMessageService) { + chatService.deleteMessage(chatMessageService, messageId) + commit('refreshLastMessage', { chatId }) + } + }, + resetChatNewMessageCount (state, _value) { + const chatMessageService = state.openedChatMessageServices[state.currentChatId] + chatService.resetNewMessageCount(chatMessageService) + }, + // Used when a connection loss occurs + clearOpenedChats (state) { + const currentChatId = state.currentChatId + for (const chatId in state.openedChats) { + if (currentChatId !== chatId) { + chatService.clear(state.openedChatMessageServices[chatId]) + Vue.delete(state.openedChats, chatId) + Vue.delete(state.openedChatMessageServices, chatId) + } + } + }, + readChat (state, { id }) { + const chat = getChatById(state, id) + if (chat) { + chat.unread = 0 + } + } + } +} + +export default chats diff --git a/src/modules/config.js b/src/modules/config.js index 47b24d77..e0fe72df 100644 --- a/src/modules/config.js +++ b/src/modules/config.js @@ -46,7 +46,8 @@ export const defaultState = { repeats: true, moves: true, emojiReactions: false, - followRequest: true + followRequest: true, + chatMention: true }, webPushNotifications: false, muteWords: [], diff --git a/src/modules/instance.js b/src/modules/instance.js index 45a8eeca..3fe3bbf3 100644 --- a/src/modules/instance.js +++ b/src/modules/instance.js @@ -55,6 +55,7 @@ const defaultState = { // Feature-set, apparently, not everything here is reported... chatAvailable: false, + pleromaChatMessagesAvailable: false, gopherAvailable: false, mediaProxyAvailable: false, suggestionsEnabled: false, diff --git a/src/modules/interface.js b/src/modules/interface.js index e31630fc..ec08ac0a 100644 --- a/src/modules/interface.js +++ b/src/modules/interface.js @@ -15,7 +15,8 @@ const defaultState = { ) }, mobileLayout: false, - globalNotices: [] + globalNotices: [], + layoutHeight: 0 } const interfaceMod = { @@ -65,6 +66,9 @@ const interfaceMod = { }, removeGlobalNotice (state, notice) { state.globalNotices = state.globalNotices.filter(n => n !== notice) + }, + setLayoutHeight (state, value) { + state.layoutHeight = value } }, actions: { @@ -110,6 +114,9 @@ const interfaceMod = { }, removeGlobalNotice ({ commit }, notice) { commit('removeGlobalNotice', notice) + }, + setLayoutHeight ({ commit }, value) { + commit('setLayoutHeight', value) } } } diff --git a/src/modules/statuses.js b/src/modules/statuses.js index 7fbf685c..64f5b587 100644 --- a/src/modules/statuses.js +++ b/src/modules/statuses.js @@ -478,7 +478,7 @@ export const mutations = { }, setDeleted (state, { status }) { const newStatus = state.allStatusesObject[status.id] - newStatus.deleted = true + if (newStatus) newStatus.deleted = true }, setManyDeleted (state, condition) { Object.values(state.allStatusesObject).forEach(status => { @@ -521,6 +521,9 @@ export const mutations = { dismissNotification (state, { id }) { state.notifications.data = state.notifications.data.filter(n => n.id !== id) }, + dismissNotifications (state, { finder }) { + state.notifications.data = state.notifications.data.filter(n => finder) + }, updateNotification (state, { id, updater }) { const notification = find(state.notifications.data, n => n.id === id) notification && updater(notification) diff --git a/src/modules/users.js b/src/modules/users.js index 7e136c61..16c1e566 100644 --- a/src/modules/users.js +++ b/src/modules/users.js @@ -498,6 +498,7 @@ const users = { store.dispatch('stopFetchingFollowRequests') store.commit('clearNotifications') store.commit('resetStatuses') + store.dispatch('resetChats') }) }, loginUser (store, accessToken) { @@ -537,6 +538,9 @@ const users = { // Start fetching notifications store.dispatch('startFetchingNotifications') + + // Start fetching chats + store.dispatch('startFetchingChats') } if (store.getters.mergedConfig.useStreamingApi) { @@ -544,6 +548,7 @@ const users = { console.error('Failed initializing MastoAPI Streaming socket', error) startPolling() }).then(() => { + store.dispatch('fetchChats', { latest: true }) setTimeout(() => store.dispatch('setNotificationsSilence', false), 10000) }) } else { diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js index 14e63e4f..5428cc2a 100644 --- a/src/services/api/api.service.js +++ b/src/services/api/api.service.js @@ -1,5 +1,5 @@ import { each, map, concat, last, get } from 'lodash' -import { parseStatus, parseUser, parseNotification, parseAttachment, parseLinkHeaderPagination } from '../entity_normalizer/entity_normalizer.service.js' +import { parseStatus, parseUser, parseNotification, parseAttachment, parseChat, parseLinkHeaderPagination } from '../entity_normalizer/entity_normalizer.service.js' import { RegistrationError, StatusCodeError } from '../errors/errors' /* eslint-env browser */ @@ -81,6 +81,11 @@ const MASTODON_KNOWN_DOMAIN_LIST_URL = '/api/v1/instance/peers' const PLEROMA_EMOJI_REACTIONS_URL = id => `/api/v1/pleroma/statuses/${id}/reactions` const PLEROMA_EMOJI_REACT_URL = (id, emoji) => `/api/v1/pleroma/statuses/${id}/reactions/${emoji}` const PLEROMA_EMOJI_UNREACT_URL = (id, emoji) => `/api/v1/pleroma/statuses/${id}/reactions/${emoji}` +const PLEROMA_CHATS_URL = `/api/v1/pleroma/chats` +const PLEROMA_CHAT_URL = id => `/api/v1/pleroma/chats/by-account-id/${id}` +const PLEROMA_CHAT_MESSAGES_URL = id => `/api/v1/pleroma/chats/${id}/messages` +const PLEROMA_CHAT_READ_URL = id => `/api/v1/pleroma/chats/${id}/read` +const PLEROMA_DELETE_CHAT_MESSAGE_URL = (chatId, messageId) => `/api/v1/pleroma/chats/${chatId}/messages/${messageId}` const oldfetch = window.fetch @@ -117,13 +122,18 @@ const promisedRequest = ({ method, url, params, payload, credentials, headers = } return fetch(url, options) .then((response) => { - return new Promise((resolve, reject) => response.json() - .then((json) => { - if (!response.ok) { - return reject(new StatusCodeError(response.status, json, { url, options }, response)) - } - return resolve(json) - })) + return new Promise((resolve, reject) => { + response.json() + .then((json) => { + if (!response.ok) { + return reject(new StatusCodeError(response.status, json, { url, options }, response)) + } + return resolve(json) + }) + .catch((error) => { + return reject(new StatusCodeError(response.status, error.message, { url, options }, response)) + }) + }) }) } @@ -1067,6 +1077,10 @@ const MASTODON_STREAMING_EVENTS = new Set([ 'filters_changed' ]) +const PLEROMA_STREAMING_EVENTS = new Set([ + 'pleroma:chat_update' +]) + // A thin wrapper around WebSocket API that allows adding a pre-processor to it // Uses EventTarget and a CustomEvent to proxy events export const ProcessedWS = ({ @@ -1123,7 +1137,7 @@ export const handleMastoWS = (wsEvent) => { if (!data) return const parsedEvent = JSON.parse(data) const { event, payload } = parsedEvent - if (MASTODON_STREAMING_EVENTS.has(event)) { + if (MASTODON_STREAMING_EVENTS.has(event) || PLEROMA_STREAMING_EVENTS.has(event)) { // MastoBE and PleromaBE both send payload for delete as a PLAIN string if (event === 'delete') { return { event, id: payload } @@ -1133,6 +1147,8 @@ export const handleMastoWS = (wsEvent) => { return { event, status: parseStatus(data) } } else if (event === 'notification') { return { event, notification: parseNotification(data) } + } else if (event === 'pleroma:chat_update') { + return { event, chatUpdate: parseChat(data) } } } else { console.warn('Unknown event', wsEvent) @@ -1140,6 +1156,81 @@ export const handleMastoWS = (wsEvent) => { } } +export const WSConnectionStatus = Object.freeze({ + 'JOINED': 1, + 'CLOSED': 2, + 'ERROR': 3 +}) + +const chats = ({ credentials }) => { + return fetch(PLEROMA_CHATS_URL, { headers: authHeaders(credentials) }) + .then((data) => data.json()) + .then((data) => { + return { chats: data.map(parseChat).filter(c => c) } + }) +} + +const getOrCreateChat = ({ accountId, credentials }) => { + return promisedRequest({ + url: PLEROMA_CHAT_URL(accountId), + method: 'POST', + credentials + }) +} + +const chatMessages = ({ id, credentials, maxId, sinceId, limit = 20 }) => { + let url = PLEROMA_CHAT_MESSAGES_URL(id) + const args = [ + maxId && `max_id=${maxId}`, + sinceId && `since_id=${sinceId}`, + limit && `limit=${limit}` + ].filter(_ => _).join('&') + + url = url + (args ? '?' + args : '') + + return promisedRequest({ + url, + method: 'GET', + credentials + }) +} + +const sendChatMessage = ({ id, content, mediaId = null, credentials }) => { + const payload = { + 'content': content + } + + if (mediaId) { + payload['media_id'] = mediaId + } + + return promisedRequest({ + url: PLEROMA_CHAT_MESSAGES_URL(id), + method: 'POST', + payload: payload, + credentials + }) +} + +const readChat = ({ id, lastReadId, credentials }) => { + return promisedRequest({ + url: PLEROMA_CHAT_READ_URL(id), + method: 'POST', + payload: { + 'last_read_id': lastReadId + }, + credentials + }) +} + +const deleteChatMessage = ({ chatId, messageId, credentials }) => { + return promisedRequest({ + url: PLEROMA_DELETE_CHAT_MESSAGE_URL(chatId, messageId), + method: 'DELETE', + credentials + }) +} + const apiService = { verifyCredentials, fetchTimeline, @@ -1218,7 +1309,13 @@ const apiService = { fetchKnownDomains, fetchDomainMutes, muteDomain, - unmuteDomain + unmuteDomain, + chats, + getOrCreateChat, + chatMessages, + sendChatMessage, + readChat, + deleteChatMessage } export default apiService diff --git a/src/services/chat_service/chat_service.js b/src/services/chat_service/chat_service.js new file mode 100644 index 00000000..763a7607 --- /dev/null +++ b/src/services/chat_service/chat_service.js @@ -0,0 +1,150 @@ +import _ from 'lodash' + +const empty = (chatId) => { + return { + idIndex: {}, + messages: [], + newMessageCount: 0, + lastSeenTimestamp: 0, + chatId: chatId, + minId: undefined, + lastMessage: undefined + } +} + +const clear = (storage) => { + storage.idIndex = {} + storage.messages.splice(0, storage.messages.length) + storage.newMessageCount = 0 + storage.lastSeenTimestamp = 0 + storage.minId = undefined + storage.lastMessage = undefined +} + +const deleteMessage = (storage, messageId) => { + if (!storage) { return } + storage.messages = storage.messages.filter(m => m.id !== messageId) + delete storage.idIndex[messageId] + + if (storage.lastMessage && (storage.lastMessage.id === messageId)) { + storage.lastMessage = _.maxBy(storage.messages, 'id') + } + + if (storage.minId === messageId) { + storage.minId = _.minBy(storage.messages, 'id') + } +} + +const add = (storage, { messages: newMessages }) => { + if (!storage) { return } + for (let i = 0; i < newMessages.length; i++) { + const message = newMessages[i] + + // sanity check + if (message.chat_id !== storage.chatId) { return } + + if (!storage.minId || message.id < storage.minId) { + storage.minId = message.id + } + + if (!storage.lastMessage || message.id > storage.lastMessage.id) { + storage.lastMessage = message + } + + if (!storage.idIndex[message.id]) { + if (storage.lastSeenTimestamp < message.created_at) { + storage.newMessageCount++ + } + storage.messages.push(message) + storage.idIndex[message.id] = message + } + } +} + +const resetNewMessageCount = (storage) => { + if (!storage) { return } + storage.newMessageCount = 0 + storage.lastSeenTimestamp = new Date() +} + +// Inserts date separators and marks the head and tail if it's the chain of messages made by the same user +const getView = (storage) => { + if (!storage) { return [] } + + const result = [] + const messages = _.sortBy(storage.messages, ['id', 'desc']) + const firstMessages = messages[0] + let prev = messages[messages.length - 1] + let currentMessageChainId + + if (firstMessages) { + const date = new Date(firstMessages.created_at) + date.setHours(0, 0, 0, 0) + result.push({ + type: 'date', + date, + id: date.getTime().toString() + }) + } + + let afterDate = false + + for (let i = 0; i < messages.length; i++) { + const message = messages[i] + const nextMessage = messages[i + 1] + + const date = new Date(message.created_at) + date.setHours(0, 0, 0, 0) + + // insert date separator and start a new message chain + if (prev && prev.date < date) { + result.push({ + type: 'date', + date, + id: date.getTime().toString() + }) + + prev['isTail'] = true + currentMessageChainId = undefined + afterDate = true + } + + const object = { + type: 'message', + data: message, + date, + id: message.id, + messageChainId: currentMessageChainId + } + + // end a message chian + if ((nextMessage && nextMessage.account_id) !== message.account_id) { + object['isTail'] = true + currentMessageChainId = undefined + } + + // start a new message chain + if ((prev && prev.data && prev.data.account_id) !== message.account_id || afterDate) { + currentMessageChainId = _.uniqueId() + object['isHead'] = true + object['messageChainId'] = currentMessageChainId + } + + result.push(object) + prev = object + afterDate = false + } + + return result +} + +const ChatService = { + add, + empty, + getView, + deleteMessage, + resetNewMessageCount, + clear +} + +export default ChatService diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js index ec83c02a..7ea8a16c 100644 --- a/src/services/entity_normalizer/entity_normalizer.service.js +++ b/src/services/entity_normalizer/entity_normalizer.service.js @@ -183,6 +183,7 @@ export const parseUser = (data) => { output.deactivated = data.pleroma.deactivated output.notification_settings = data.pleroma.notification_settings + output.unread_chat_count = data.pleroma.unread_chat_count } output.tags = output.tags || [] @@ -372,7 +373,7 @@ export const parseNotification = (data) => { ? parseStatus(data.notice.favorited_status) : parsedNotice output.action = parsedNotice - output.from_profile = parseUser(data.from_profile) + output.from_profile = output.type === 'pleroma:chat_mention' ? parseUser(data.account) : parseUser(data.from_profile) } output.created_at = new Date(data.created_at) @@ -398,3 +399,34 @@ export const parseLinkHeaderPagination = (linkHeader, opts = {}) => { minId: flakeId ? minId : parseInt(minId, 10) } } + +export const parseChat = (chat) => { + const output = {} + output.id = chat.id + output.account = parseUser(chat.account) + output.unread = chat.unread + output.lastMessage = parseChatMessage(chat.last_message) + output.updated_at = new Date(chat.updated_at) + return output +} + +export const parseChatMessage = (message) => { + if (!message) { return } + if (message.isNormalized) { return message } + const output = message + output.id = message.id + output.created_at = new Date(message.created_at) + output.chat_id = message.chat_id + if (message.content) { + output.content = addEmojis(message.content, message.emojis) + } else { + output.content = '' + } + if (message.attachment) { + output.attachments = [parseAttachment(message.attachment)] + } else { + output.attachments = [] + } + output.isNormalized = true + return output +} diff --git a/src/services/style_setter/style_setter.js b/src/services/style_setter/style_setter.js index fbdcf562..07425abd 100644 --- a/src/services/style_setter/style_setter.js +++ b/src/services/style_setter/style_setter.js @@ -106,7 +106,8 @@ export const generateRadii = (input) => { avatar: 5, avatarAlt: 50, tooltip: 2, - attachment: 5 + attachment: 5, + chatMessage: inputRadii.panel }) return { diff --git a/src/services/theme_data/pleromafe.js b/src/services/theme_data/pleromafe.js index 6b25cd6f..b58ca9be 100644 --- a/src/services/theme_data/pleromafe.js +++ b/src/services/theme_data/pleromafe.js @@ -23,7 +23,9 @@ export const LAYERS = { inputTopBar: 'topBar', alert: 'bg', alertPanel: 'panel', - poll: 'bg' + poll: 'bg', + chatBg: 'underlay', + chatMessage: 'chatBg' } /* By default opacity slots have 1 as default opacity @@ -667,5 +669,54 @@ export const SLOT_INHERITANCE = { layer: 'badge', variant: 'badgeNotification', textColor: 'bw' + }, + + chatBg: { + depends: ['bg'] + }, + + chatMessage: { + depends: ['chatBg'] + }, + + chatMessageIncomingBg: { + depends: ['chatMessage'], + layer: 'chatMessage' + }, + + chatMessageIncomingText: { + depends: ['text'], + layer: 'text' + }, + + chatMessageIncomingLink: { + depends: ['link'], + layer: 'link' + }, + + chatMessageIncomingBorder: { + depends: ['border'], + opacity: 'border', + color: (mod, border) => brightness(2 * mod, border).rgb + }, + + chatMessageOutgoingBg: { + depends: ['chatMessage'], + color: (mod, chatMessage) => brightness(5 * mod, chatMessage).rgb + }, + + chatMessageOutgoingText: { + depends: ['text'], + layer: 'text' + }, + + chatMessageOutgoingLink: { + depends: ['link'], + layer: 'link' + }, + + chatMessageOutgoingBorder: { + depends: ['chatMessage'], + opacity: 'chatMessage' } } diff --git a/src/services/window_utils/window_utils.js b/src/services/window_utils/window_utils.js index faff6cb9..909088db 100644 --- a/src/services/window_utils/window_utils.js +++ b/src/services/window_utils/window_utils.js @@ -3,3 +3,8 @@ export const windowWidth = () => window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth + +export const windowHeight = () => + window.innerHeight || + document.documentElement.clientHeight || + document.body.clientHeight diff --git a/static/fontello.json b/static/fontello.json index 5ef8544e..706800cd 100644 --- a/static/fontello.json +++ b/static/fontello.json @@ -399,6 +399,12 @@ "css": "doc", "code": 59433, "src": "fontawesome" + }, + { + "uid": "98d9c83c1ee7c2c25af784b518c522c5", + "css": "block", + "code": 59434, + "src": "fontawesome" } ] } \ No newline at end of file diff --git a/test/unit/specs/boot/routes.spec.js b/test/unit/specs/boot/routes.spec.js index a415aeaf..3673256f 100644 --- a/test/unit/specs/boot/routes.spec.js +++ b/test/unit/specs/boot/routes.spec.js @@ -1,14 +1,22 @@ +import Vuex from 'vuex' import routes from 'src/boot/routes' import { createLocalVue } from '@vue/test-utils' import VueRouter from 'vue-router' const localVue = createLocalVue() +localVue.use(Vuex) localVue.use(VueRouter) +const store = new Vuex.Store({ + state: { + instance: {} + } +}) + describe('routes', () => { const router = new VueRouter({ mode: 'abstract', - routes: routes({}) + routes: routes(store) }) it('root path', () => { From f05f832bff58034d78de9478ae2dbb06284dea75 Mon Sep 17 00:00:00 2001 From: eugenijm <eugenijm@protonmail.com> Date: Sun, 21 Jun 2020 17:13:29 +0300 Subject: [PATCH 2/8] Address feedback Use more specific css rules for the emoji dimensions in the chat list status preview. Use more round em value for chat list item height. Add global html overflow and height for smoother chat navigation in the desktop Safari. Use offsetHeight instad of a computed style when setting the window height on resize. Remove margin-bottom from the last message to avoid occasional layout shift in the desktop Safari Use break-word to prevent chat message text overflow Resize and scroll the textarea when inserting a new line on ctrl+enter Remove fade transition on route change Ensure proper border radius at the bottom of the chat, remove unused border-radius Prevent the chat header "jumping" on the avatar load. --- src/App.js | 12 +-- src/App.scss | 39 ++++++- src/App.vue | 4 +- src/components/chat/chat.js | 63 ++++++++--- src/components/chat/chat.scss | 32 +++--- src/components/chat/chat.vue | 2 +- src/components/chat/chat_layout.js | 100 ------------------ src/components/chat/chat_layout_utils.js | 3 +- src/components/chat_avatar/chat_avatar.js | 23 ---- src/components/chat_avatar/chat_avatar.vue | 53 ---------- .../chat_list_item/chat_list_item.js | 4 +- .../chat_list_item/chat_list_item.scss | 48 ++++----- .../chat_list_item/chat_list_item.vue | 2 +- src/components/chat_message/chat_message.js | 4 +- src/components/chat_message/chat_message.scss | 27 ++--- src/components/chat_new/chat_new.js | 5 +- src/components/chat_new/chat_new.scss | 2 +- src/components/chat_title/chat_title.js | 10 +- src/components/chat_title/chat_title.vue | 39 ++++--- src/components/emoji_input/emoji_input.js | 23 +++- src/components/media_upload/media_upload.vue | 13 --- .../post_status_form/post_status_form.js | 19 ++-- .../post_status_form/post_status_form.vue | 20 +++- src/services/chat_service/chat_service.js | 19 ++-- .../chat_service/chat_service.spec.js | 89 ++++++++++++++++ 25 files changed, 317 insertions(+), 338 deletions(-) delete mode 100644 src/components/chat/chat_layout.js delete mode 100644 src/components/chat_avatar/chat_avatar.js delete mode 100644 src/components/chat_avatar/chat_avatar.vue create mode 100644 test/unit/specs/services/chat_service/chat_service.spec.js diff --git a/src/App.js b/src/App.js index 84300e00..ded772fa 100644 --- a/src/App.js +++ b/src/App.js @@ -45,8 +45,7 @@ export default { window.CSS.supports('-moz-mask-size', 'contain') || window.CSS.supports('-ms-mask-size', 'contain') || window.CSS.supports('-o-mask-size', 'contain') - ), - transitionName: 'fade' + ) }), created () { // Load the locale from the storage @@ -135,14 +134,5 @@ export default { } this.$store.dispatch('setLayoutHeight', layoutHeight) } - }, - watch: { - '$route' (to, from) { - if ((to.name === 'chat' && from.name === 'chats') || (to.name === 'chats' && from.name === 'chat')) { - this.transitionName = 'none' - } else { - this.transitionName = 'fade' - } - } } } diff --git a/src/App.scss b/src/App.scss index 29ce73a8..e2e2d079 100644 --- a/src/App.scss +++ b/src/App.scss @@ -47,6 +47,7 @@ html { } body { + overscroll-behavior-y: none; font-family: sans-serif; font-family: var(--interfaceFont, sans-serif); margin: 0; @@ -56,7 +57,6 @@ body { overflow-x: hidden; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - overscroll-behavior: none; &.hidden { display: none; @@ -320,7 +320,7 @@ option { i[class*=icon-] { color: $fallback--icon; - color: var(--icon, $fallback--icon) + color: var(--icon, $fallback--icon); } .btn-block { @@ -942,3 +942,38 @@ nav { max-height: 1.3rem; line-height: 1.3rem; } + +.chat-layout { + // Needed for smoother chat navigation in the desktop Safari (otherwise the chat layout "jumps" as the chat opens). + overflow: hidden; + height: 100%; + + // Ensures the fixed position of the mobile browser bars on scroll up / down events. + // Prevents the mobile browser bars from overlapping or hiding the message posting form. + @media all and (max-width: 800px) { + body { + height: 100%; + } + + #app { + height: 100%; + overflow: hidden; + min-height: auto; + } + + #app_bg_wrapper { + overflow: hidden; + } + + .main { + overflow: hidden; + height: 100%; + } + + #content { + padding-top: 0; + height: 100%; + overflow: visible; + } + } +} diff --git a/src/App.vue b/src/App.vue index 5d429934..0276c6a6 100644 --- a/src/App.vue +++ b/src/App.vue @@ -113,9 +113,7 @@ {{ $t("login.hint") }} </router-link> </div> - <transition :name="transitionName"> - <router-view /> - </transition> + <router-view /> </div> <media-modal /> </div> diff --git a/src/components/chat/chat.js b/src/components/chat/chat.js index 6e23c20c..9c4e5b05 100644 --- a/src/components/chat/chat.js +++ b/src/components/chat/chat.js @@ -2,29 +2,26 @@ 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 SAFE_RESIZE_TIME_OFFSET = 100 const Chat = { components: { ChatMessage, ChatTitle, - ChatAvatar, PostStatusForm }, - mixins: [ChatLayout], data () { return { jumpToBottomButtonVisible: false, hoveredMessageChainId: undefined, - scrollPositionBeforeResize: {}, + lastScrollPosition: {}, scrollableContainerHeight: '100%', errorLoadingChat: false } @@ -119,6 +116,7 @@ const Chat = { }, onFilesDropped () { this.$nextTick(() => { + this.handleResize() this.updateScrollableContainerHeight() }) }, @@ -129,13 +127,30 @@ const Chat = { } }) }, - handleLayoutChange () { - this.updateScrollableContainerHeight() - if (this.mobileLayout) { - this.setMobileChatLayout() - } else { - this.unsetMobileChatLayout() + 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() @@ -149,15 +164,24 @@ const Chat = { this.scrollableContainerHeight = scrollableContainerHeight(inner, header, footer) + 'px' }, // Preserves the scroll position when OSK appears or the posting form changes its height. - handleResize (opts) { + 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.scrollPositionBeforeResize - this.scrollPositionBeforeResize = getScrollPosition(this.$refs.scrollable) + const { offsetHeight = undefined } = this.lastScrollPosition + this.lastScrollPosition = getScrollPosition(this.$refs.scrollable) - const diff = this.scrollPositionBeforeResize.offsetHeight - offsetHeight - if (diff < 0 || (!this.bottomedOut() && opts && opts.expand)) { + const diff = this.lastScrollPosition.offsetHeight - offsetHeight + if (diff < 0 || (!this.bottomedOut() && expand)) { this.$nextTick(() => { this.updateScrollableContainerHeight() this.$refs.scrollable.scrollTo({ @@ -281,7 +305,12 @@ const Chat = { .then(data => { this.$store.dispatch('addChatMessages', { chatId: this.currentChat.id, messages: [data] }).then(() => { this.$nextTick(() => { - this.updateScrollableContainerHeight() + 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 }) }) }) diff --git a/src/components/chat/chat.scss b/src/components/chat/chat.scss index 13c52ea3..6ae7ebc9 100644 --- a/src/components/chat/chat.scss +++ b/src/components/chat/chat.scss @@ -3,14 +3,17 @@ height: calc(100vh - 60px); width: 100%; + .chat-title { + // prevents chat header jumping on when the user avatar loads + height: 28px; + } + .chat-view-inner { height: auto; width: 100%; overflow: visible; display: flex; - margin-top: 0.5em; - margin-left: 0.5em; - margin-right: 0.5em; + margin: 0.5em 0.5em 0 0.5em; } .chat-view-body { @@ -19,23 +22,18 @@ 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; + margin: 0 0 0 0; border-radius: 10px 10px 0 0; border-radius: var(--panelRadius, 10px) var(--panelRadius, 10px) 0 0 ; &::after { - border-radius: none; - box-shadow: none; + border-radius: 0; } } .scrollable-message-list { - padding: 0 10px; + padding: 0 0.8em; height: 100%; overflow-y: scroll; overflow-x: hidden; @@ -45,7 +43,7 @@ .footer { position: sticky; - bottom: 0px; + bottom: 0; } .chat-view-heading { @@ -54,15 +52,19 @@ 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; + margin-right: 1.4em; + + i { + display: flex; + align-items: center; + } } .jump-to-bottom-button { @@ -135,7 +137,7 @@ overflow: hidden; height: 100%; margin: 0; - border-radius: 0 !important; + border-radius: 0; } .chat-view-heading { diff --git a/src/components/chat/chat.vue b/src/components/chat/chat.vue index d8c91dbe..62b72e14 100644 --- a/src/components/chat/chat.vue +++ b/src/components/chat/chat.vue @@ -75,7 +75,7 @@ :disable-polls="true" :disable-sensitivity-checkbox="true" :disable-submit="errorLoadingChat || !currentChat" - :request="sendMessage" + :post-handler="sendMessage" :submit-on-enter="!mobileLayout" :preserve-focus="!mobileLayout" :auto-focus="!mobileLayout" diff --git a/src/components/chat/chat_layout.js b/src/components/chat/chat_layout.js deleted file mode 100644 index 07ae3abf..00000000 --- a/src/components/chat/chat_layout.js +++ /dev/null @@ -1,100 +0,0 @@ -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 index f07ba2a1..609dc0c9 100644 --- a/src/components/chat/chat_layout_utils.js +++ b/src/components/chat/chat_layout_utils.js @@ -22,6 +22,5 @@ export const isBottomedOut = (el, offset = 0) => { // 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 + return inner.offsetHeight - header.clientHeight - footer.clientHeight } diff --git a/src/components/chat_avatar/chat_avatar.js b/src/components/chat_avatar/chat_avatar.js deleted file mode 100644 index 7b26e07c..00000000 --- a/src/components/chat_avatar/chat_avatar.js +++ /dev/null @@ -1,23 +0,0 @@ -import StillImage from '../still-image/still-image.vue' -import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' -import { mapState } from 'vuex' - -const ChatAvatar = { - props: ['user', 'width', 'height'], - components: { - StillImage - }, - methods: { - getUserProfileLink (user) { - if (!user) { return } - return generateProfileLink(user.id, user.screen_name) - } - }, - computed: { - ...mapState({ - betterShadow: state => state.interface.browserSupport.cssFilter - }) - } -} - -export default ChatAvatar diff --git a/src/components/chat_avatar/chat_avatar.vue b/src/components/chat_avatar/chat_avatar.vue deleted file mode 100644 index f54a7151..00000000 --- a/src/components/chat_avatar/chat_avatar.vue +++ /dev/null @@ -1,53 +0,0 @@ -<template> - <router-link - :to="getUserProfileLink(user) || ''" - > - <StillImage - v-if="user" - :style="{ 'width': width, 'height': height }" - class="avatar chat-avatar single-user" - :alt="user.screen_name" - :title="user.screen_name" - :src="user.profile_image_url_original" - error-src="/images/avi.png" - :class="{ 'better-shadow': betterShadow }" - /> - <div - v-else - class="avatar chat-avatar single-user" - :style="{ 'width': width, 'height': height }" - /> - </router-link> -</template> - -<script src="./chat_avatar.js"></script> -<style lang="scss"> -@import '../../_variables.scss'; - -.chat-avatar { - display: inline-block; - vertical-align: middle; - - &.single-user { - border-radius: $fallback--avatarAltRadius; - border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius); - } - - .avatar.still-image { - width: 48px; - height: 48px; - - box-shadow: var(--avatarStatusShadow); - border-radius: 0; - - &.better-shadow { - box-shadow: var(--avatarStatusShadowInset); - filter: var(--avatarStatusShadowFilter) - } - - &.animated::before { - display: none; - } - } -} -</style> diff --git a/src/components/chat_list_item/chat_list_item.js b/src/components/chat_list_item/chat_list_item.js index 1c27088c..b6b0519a 100644 --- a/src/components/chat_list_item/chat_list_item.js +++ b/src/components/chat_list_item/chat_list_item.js @@ -1,7 +1,7 @@ import { mapState } from 'vuex' import StatusContent from '../status_content/status_content.vue' import fileType from 'src/services/file_type/file_type.service' -import ChatAvatar from '../chat_avatar/chat_avatar.vue' +import UserAvatar from '../user_avatar/user_avatar.vue' import AvatarList from '../avatar_list/avatar_list.vue' import Timeago from '../timeago/timeago.vue' import ChatTitle from '../chat_title/chat_title.vue' @@ -12,7 +12,7 @@ const ChatListItem = { 'chat' ], components: { - ChatAvatar, + UserAvatar, AvatarList, Timeago, ChatTitle, diff --git a/src/components/chat_list_item/chat_list_item.scss b/src/components/chat_list_item/chat_list_item.scss index 12269f89..3ec59ea2 100644 --- a/src/components/chat_list_item/chat_list_item.scss +++ b/src/components/chat_list_item/chat_list_item.scss @@ -1,17 +1,8 @@ .chat-list-item { - &:hover .animated.avatar { - canvas { - display: none; - } - img { - visibility: visible; - } - } - display: flex; flex-direction: row; padding: 0.75em; - height: 4.85em; + height: 5em; overflow: hidden; box-sizing: border-box; cursor: pointer; @@ -22,7 +13,7 @@ &:hover { background-color: var(--selectedPost, $fallback--lightBg); - box-shadow: 0 0px 3px 1px rgba(0, 0, 0, 0.1); + box-shadow: 0 0 3px 1px rgba(0, 0, 0, 0.1); } .chat-list-item-left { @@ -47,12 +38,6 @@ white-space: nowrap; } - .member-count { - color: $fallback--text; - color: var(--faintText, $fallback--text); - margin-right: 2px; - } - .name-and-account-name { text-overflow: ellipsis; white-space: nowrap; @@ -65,7 +50,7 @@ overflow: hidden; white-space: nowrap; text-overflow: ellipsis; - margin: 0.35rem 0; + margin: 0.35em 0; height: 1.2em; line-height: 1.2em; color: $fallback--text; @@ -78,17 +63,24 @@ pointer-events: none; } - .unread-indicator-wrapper { - display: flex; - align-items: center; - margin-left: 10px; + &:hover .animated.avatar { + canvas { + display: none; + } + img { + visibility: visible; + } } - .unread-indicator { - border-radius: 100%; - height: 8px; - width: 8px; - background-color: $fallback--link; - background-color: var(--link, $fallback--link); + .avatar.still-image { + border-radius: $fallback--avatarAltRadius; + border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius); + } + + .status-body { + img.emoji { + width: 1.4em; + height: 1.4em; + } } } diff --git a/src/components/chat_list_item/chat_list_item.vue b/src/components/chat_list_item/chat_list_item.vue index 26ad581b..640426b8 100644 --- a/src/components/chat_list_item/chat_list_item.vue +++ b/src/components/chat_list_item/chat_list_item.vue @@ -4,7 +4,7 @@ @click.capture.prevent="openChat" > <div class="chat-list-item-left"> - <ChatAvatar + <UserAvatar :user="chat.account" height="48px" width="48px" diff --git a/src/components/chat_message/chat_message.js b/src/components/chat_message/chat_message.js index aba95074..4d737e42 100644 --- a/src/components/chat_message/chat_message.js +++ b/src/components/chat_message/chat_message.js @@ -66,9 +66,9 @@ const ChatMessage = { } if (this.isCurrentUser) { - res.right = '5px' + res.right = '0.4rem' } else { - res.left = '5px' + res.left = '0.4rem' } return res diff --git a/src/components/chat_message/chat_message.scss b/src/components/chat_message/chat_message.scss index e4028537..9d7b7936 100644 --- a/src/components/chat_message/chat_message.scss +++ b/src/components/chat_message/chat_message.scss @@ -12,19 +12,15 @@ } } - &:last-child { - margin-bottom: 16px; - } - .chat-message-menu { transition: opacity 0.1s; opacity: 0; position: absolute; - top: -10px; + top: -0.8em; button { - padding-top: 3px; - padding-bottom: 3px; + padding-top: 0.2em; + padding-bottom: 0.2em; } } @@ -41,21 +37,21 @@ } .popover { - width: 12rem; + width: 12em; } .chat-message { display: flex; - padding-bottom: 7px; + padding-bottom: 0.5em; } .avatar-wrapper { - margin-right: 10px; + margin-right: 0.72em; width: 32px; } .link-preview, .attachments { - margin-bottom: 0.9em; + margin-bottom: 1em; } .chat-message-inner { @@ -63,7 +59,7 @@ flex-direction: column; align-items: flex-start; max-width: 80%; - min-width: 10rem; + min-width: 10em; width: 100%; &.with-media { @@ -87,19 +83,18 @@ } .created-at { + position: relative; float: right; font-size: 0.8em; - margin: -10px 0 -5px 4px; + margin: -1em 0 -0.5em 0; font-style: italic; opacity: 0.8; } .without-attachment { .status-content { - white-space: normal; - &::after { - margin-right: 75px; + margin-right: 5.4em; content: " "; display: inline-block; } diff --git a/src/components/chat_new/chat_new.js b/src/components/chat_new/chat_new.js index 0da681f7..d023efc0 100644 --- a/src/components/chat_new/chat_new.js +++ b/src/components/chat_new/chat_new.js @@ -1,4 +1,3 @@ -import { throttle } from 'lodash' import { mapState, mapGetters } from 'vuex' import BasicUserCard from '../basic_user_card/basic_user_card.vue' import UserAvatar from '../user_avatar/user_avatar.vue' @@ -54,7 +53,7 @@ const chatNew = { removeUser (userId) { this.selectedUserIds = this.selectedUserIds.filter(id => id !== userId) }, - search: throttle(function (query) { + search (query) { if (!query) { this.loading = false return @@ -67,7 +66,7 @@ const chatNew = { this.loading = false this.userIds = data.accounts.map(a => a.id) }) - }) + } } } diff --git a/src/components/chat_new/chat_new.scss b/src/components/chat_new/chat_new.scss index 39216677..11305444 100644 --- a/src/components/chat_new/chat_new.scss +++ b/src/components/chat_new/chat_new.scss @@ -15,7 +15,7 @@ } .member-list { - padding-bottom: 0.67rem; + padding-bottom: 0.7rem; } .basic-user-card:hover { diff --git a/src/components/chat_title/chat_title.js b/src/components/chat_title/chat_title.js index 2723d5f5..e424bb1f 100644 --- a/src/components/chat_title/chat_title.js +++ b/src/components/chat_title/chat_title.js @@ -1,10 +1,11 @@ import Vue from 'vue' -import ChatAvatar from '../chat_avatar/chat_avatar.vue' +import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' +import UserAvatar from '../user_avatar/user_avatar.vue' export default Vue.component('chat-title', { name: 'ChatTitle', components: { - ChatAvatar + UserAvatar }, props: [ 'user', 'withAvatar' @@ -16,5 +17,10 @@ export default Vue.component('chat-title', { htmlTitle () { return this.user ? this.user.name_html : '' } + }, + methods: { + getUserProfileLink (user) { + return generateProfileLink(user.id, user.screen_name) + } } }) diff --git a/src/components/chat_title/chat_title.vue b/src/components/chat_title/chat_title.vue index fd42d125..cfd1e6d1 100644 --- a/src/components/chat_title/chat_title.vue +++ b/src/components/chat_title/chat_title.vue @@ -4,16 +4,16 @@ class="chat-title" :title="title" > - <ChatAvatar - v-if="withAvatar" - :user="user" - width="23px" - height="23px" - /> - <span - v-if="withAvatar" - style="margin-right: 0.5em" - /> + <router-link + v-if="withAvatar && user" + :to="getUserProfileLink(user)" + > + <UserAvatar + :user="user" + width="23px" + height="23px" + /> + </router-link> <span class="username" v-html="htmlTitle" @@ -32,11 +32,7 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - - a { - display: flex; - align-items: center; - } + align-items: center; .username { max-width: 100%; @@ -52,5 +48,18 @@ object-fit: contain } } + + .still-image.avatar { + width: 23px; + height: 23px; + margin-right: 0.5em; + + border-radius: $fallback--avatarAltRadius; + border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius); + + &.animated::before { + display: none; + } + } } </style> diff --git a/src/components/emoji_input/emoji_input.js b/src/components/emoji_input/emoji_input.js index a27da090..f0123447 100644 --- a/src/components/emoji_input/emoji_input.js +++ b/src/components/emoji_input/emoji_input.js @@ -88,6 +88,11 @@ const EmojiInput = { required: false, type: String, // 'auto', 'top', 'bottom' default: 'auto' + }, + newlineOnCtrlEnter: { + required: false, + type: Boolean, + default: false } }, data () { @@ -204,7 +209,7 @@ const EmojiInput = { this.$emit('input', newValue) this.caret = 0 }, - insert ({ insertion, keepOpen }) { + insert ({ insertion, keepOpen, surroundingSpace = true }) { const before = this.value.substring(0, this.caret) || '' const after = this.value.substring(this.caret) || '' @@ -223,8 +228,8 @@ const EmojiInput = { * them, masto seem to be rendering :emoji::emoji: correctly now so why not */ const isSpaceRegex = /\s/ - const spaceBefore = !isSpaceRegex.exec(before.slice(-1)) && before.length && this.padEmoji > 0 ? ' ' : '' - const spaceAfter = !isSpaceRegex.exec(after[0]) && this.padEmoji ? ' ' : '' + const spaceBefore = (surroundingSpace && !isSpaceRegex.exec(before.slice(-1)) && before.length && this.padEmoji > 0) ? ' ' : '' + const spaceAfter = (surroundingSpace && !isSpaceRegex.exec(after[0]) && this.padEmoji) ? ' ' : '' const newValue = [ before, @@ -381,6 +386,18 @@ const EmojiInput = { }, onKeyDown (e) { const { ctrlKey, shiftKey, key } = e + if (this.newlineOnCtrlEnter && ctrlKey && key === 'Enter') { + this.insert({ insertion: '\n', surroundingSpace: false }) + // Ensure only one new line is added on macos + e.stopPropagation() + e.preventDefault() + + // Scroll the input element to the position of the cursor + this.$nextTick(() => { + this.input.elm.blur() + this.input.elm.focus() + }) + } // Disable suggestions hotkeys if suggestions are hidden if (!this.temporarilyHideSuggestions) { if (key === 'Tab') { diff --git a/src/components/media_upload/media_upload.vue b/src/components/media_upload/media_upload.vue index d719eae1..c8865d77 100644 --- a/src/components/media_upload/media_upload.vue +++ b/src/components/media_upload/media_upload.vue @@ -33,19 +33,6 @@ @import '../../_variables.scss'; .media-upload { - &.disabled { - .new-icon { - cursor: not-allowed; - } - - &:hover { - i, label { - color: $fallback--faint; - color: var(--faint, $fallback--faint); - } - } - } - .label { display: inline-block; } diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js index 90d0fa81..59e4dc26 100644 --- a/src/components/post_status_form/post_status_form.js +++ b/src/components/post_status_form/post_status_form.js @@ -43,7 +43,7 @@ const PostStatusForm = { 'disableSubmit', 'placeholder', 'maxHeight', - 'request', + 'postHandler', 'preserveFocus', 'autoFocus', 'fileLimit', @@ -221,10 +221,6 @@ const PostStatusForm = { event.stopPropagation() event.preventDefault() } - if (opts.control && this.submitOnEnter) { - newStatus.status = `${newStatus.status}\n` - return - } if (this.emptyStatus) { this.error = this.$t('post_status.empty_status_error') @@ -259,9 +255,9 @@ const PostStatusForm = { poll } - const request = this.request ? this.request : statusPoster.postStatus + const postHandler = this.postHandler ? this.postHandler : statusPoster.postStatus - request(postingOptions).then((data) => { + postHandler(postingOptions).then((data) => { if (!data.error) { this.newStatus = { status: '', @@ -345,11 +341,7 @@ const PostStatusForm = { }, addMediaFile (fileInfo) { this.newStatus.files.push(fileInfo) - - // TODO: use fixed dimensions instead so relying on timeout - setTimeout(() => { - this.$emit('resize') - }, 150) + this.$emit('resize', { delayed: true }) }, removeMediaFile (fileInfo) { let index = this.newStatus.files.indexOf(fileInfo) @@ -364,6 +356,7 @@ const PostStatusForm = { this.uploadingFiles = true }, finishedUploadingFiles () { + this.$emit('resize') this.uploadingFiles = false }, type (fileInfo) { @@ -417,7 +410,7 @@ const PostStatusForm = { // Reset to default height for empty form, nothing else to do here. if (target.value === '') { target.style.height = null - this.$emit('resize', null) + this.$emit('resize') this.$refs['emoji-input'].resize() return } diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue index d8df68d6..7454958b 100644 --- a/src/components/post_status_form/post_status_form.vue +++ b/src/components/post_status_form/post_status_form.vue @@ -131,6 +131,7 @@ class="form-control main-input" enable-emoji-picker hide-emoji-button + :newline-on-ctrl-enter="submitOnEnter" enable-sticker-picker @input="onEmojiInputInput" @sticker-uploaded="addMediaFile" @@ -146,8 +147,8 @@ class="form-post-body" :class="{ 'scrollable-form': !!maxHeight }" @keydown.exact.enter="submitOnEnter && postStatus($event, newStatus)" - @keydown.meta.enter="postStatus($event, newStatus, { control: true })" - @keydown.ctrl.enter="postStatus($event, newStatus)" + @keydown.meta.enter="postStatus($event, newStatus)" + @keydown.ctrl.enter="!submitOnEnter && postStatus($event, newStatus)" @input="resize" @compositionupdate="resize" @paste="paste" @@ -435,6 +436,19 @@ color: var(--lightText, $fallback--lightText); } } + + &.disabled { + i { + cursor: not-allowed; + color: $fallback--icon; + color: var(--btnDisabledText, $fallback--icon); + + &:hover { + color: $fallback--icon; + color: var(--btnDisabledText, $fallback--icon); + } + } + } } // Order is not necessary but a good indicator @@ -628,7 +642,7 @@ } // todo: unify with attachment.vue (otherwise the uploaded images are not minified unless a status with an attachment was displayed before) -img.media-upload { +img.media-upload, .media-upload-container > video { line-height: 0; max-height: 200px; max-width: 100%; diff --git a/src/services/chat_service/chat_service.js b/src/services/chat_service/chat_service.js index 763a7607..b60a889b 100644 --- a/src/services/chat_service/chat_service.js +++ b/src/services/chat_service/chat_service.js @@ -31,7 +31,8 @@ const deleteMessage = (storage, messageId) => { } if (storage.minId === messageId) { - storage.minId = _.minBy(storage.messages, 'id') + const firstMessage = _.minBy(storage.messages, 'id') + storage.minId = firstMessage.id } } @@ -73,12 +74,12 @@ const getView = (storage) => { const result = [] const messages = _.sortBy(storage.messages, ['id', 'desc']) - const firstMessages = messages[0] - let prev = messages[messages.length - 1] + const firstMessage = messages[0] + let previousMessage = messages[messages.length - 1] let currentMessageChainId - if (firstMessages) { - const date = new Date(firstMessages.created_at) + if (firstMessage) { + const date = new Date(firstMessage.created_at) date.setHours(0, 0, 0, 0) result.push({ type: 'date', @@ -97,14 +98,14 @@ const getView = (storage) => { date.setHours(0, 0, 0, 0) // insert date separator and start a new message chain - if (prev && prev.date < date) { + if (previousMessage && previousMessage.date < date) { result.push({ type: 'date', date, id: date.getTime().toString() }) - prev['isTail'] = true + previousMessage['isTail'] = true currentMessageChainId = undefined afterDate = true } @@ -124,14 +125,14 @@ const getView = (storage) => { } // start a new message chain - if ((prev && prev.data && prev.data.account_id) !== message.account_id || afterDate) { + if ((previousMessage && previousMessage.data && previousMessage.data.account_id) !== message.account_id || afterDate) { currentMessageChainId = _.uniqueId() object['isHead'] = true object['messageChainId'] = currentMessageChainId } result.push(object) - prev = object + previousMessage = object afterDate = false } diff --git a/test/unit/specs/services/chat_service/chat_service.spec.js b/test/unit/specs/services/chat_service/chat_service.spec.js new file mode 100644 index 00000000..4e8e566b --- /dev/null +++ b/test/unit/specs/services/chat_service/chat_service.spec.js @@ -0,0 +1,89 @@ +import chatService from '../../../../../src/services/chat_service/chat_service.js' + +const message1 = { + id: '9wLkdcmQXD21Oy8lEX', + created_at: (new Date('2020-06-22T18:45:53.000Z')) +} + +const message2 = { + id: '9wLkdp6ihaOVdNj8Wu', + account_id: '9vmRb29zLQReckr5ay', + created_at: (new Date('2020-06-22T18:45:56.000Z')) +} + +const message3 = { + id: '9wLke9zL4Dy4OZR2RM', + account_id: '9vmRb29zLQReckr5ay', + created_at: (new Date('2020-07-22T18:45:59.000Z')) +} + +// TODO: only +describe.only('chatService', () => { + describe('.add', () => { + it("Doesn't add duplicates", () => { + const chat = chatService.empty() + chatService.add(chat, { messages: [ message1 ] }) + chatService.add(chat, { messages: [ message1 ] }) + expect(chat.messages.length).to.eql(1) + + chatService.add(chat, { messages: [ message2 ] }) + expect(chat.messages.length).to.eql(2) + }) + + it('Updates minId and lastMessage and newMessageCount', () => { + const chat = chatService.empty() + + chatService.add(chat, { messages: [ message1 ] }) + expect(chat.lastMessage.id).to.eql(message1.id) + expect(chat.minId).to.eql(message1.id) + expect(chat.newMessageCount).to.eql(1) + + chatService.add(chat, { messages: [ message2 ] }) + expect(chat.lastMessage.id).to.eql(message2.id) + expect(chat.minId).to.eql(message1.id) + expect(chat.newMessageCount).to.eql(2) + + chatService.resetNewMessageCount(chat) + expect(chat.newMessageCount).to.eql(0) + + const createdAt = new Date() + createdAt.setSeconds(createdAt.getSeconds() + 10) + chatService.add(chat, { messages: [ { message3, created_at: createdAt } ] }) + expect(chat.newMessageCount).to.eql(1) + }) + }) + + describe('.delete', () => { + it('Updates minId and lastMessage', () => { + const chat = chatService.empty() + + chatService.add(chat, { messages: [ message1 ] }) + chatService.add(chat, { messages: [ message2 ] }) + chatService.add(chat, { messages: [ message3 ] }) + + expect(chat.lastMessage.id).to.eql(message3.id) + expect(chat.minId).to.eql(message1.id) + + chatService.deleteMessage(chat, message3.id) + expect(chat.lastMessage.id).to.eql(message2.id) + expect(chat.minId).to.eql(message1.id) + + chatService.deleteMessage(chat, message1.id) + expect(chat.lastMessage.id).to.eql(message2.id) + expect(chat.minId).to.eql(message2.id) + }) + }) + + describe('.getView', () => { + it('Inserts date separators', () => { + const chat = chatService.empty() + + chatService.add(chat, { messages: [ message1 ] }) + chatService.add(chat, { messages: [ message2 ] }) + chatService.add(chat, { messages: [ message3 ] }) + + const view = chatService.getView(chat) + expect(view.map(i => i.type)).to.eql(['date', 'message', 'message', 'date', 'message']) + }) + }) +}) From 45901c8da654bbeaae71cc484ea08f39a332baa7 Mon Sep 17 00:00:00 2001 From: eugenijm <eugenijm@protonmail.com> Date: Mon, 6 Jul 2020 16:55:29 +0300 Subject: [PATCH 3/8] Disable status preview in the chat posting form --- src/components/chat/chat.vue | 1 + src/components/post_status_form/post_status_form.js | 1 + src/components/post_status_form/post_status_form.vue | 5 ++++- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/chat/chat.vue b/src/components/chat/chat.vue index 62b72e14..2e4538c8 100644 --- a/src/components/chat/chat.vue +++ b/src/components/chat/chat.vue @@ -75,6 +75,7 @@ :disable-polls="true" :disable-sensitivity-checkbox="true" :disable-submit="errorLoadingChat || !currentChat" + :disable-preview="true" :post-handler="sendMessage" :submit-on-enter="!mobileLayout" :preserve-focus="!mobileLayout" diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js index 59e4dc26..9e7cce0f 100644 --- a/src/components/post_status_form/post_status_form.js +++ b/src/components/post_status_form/post_status_form.js @@ -41,6 +41,7 @@ const PostStatusForm = { 'disablePolls', 'disableSensitivityCheckbox', 'disableSubmit', + 'disablePreview', 'placeholder', 'maxHeight', 'postHandler', diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue index 7454958b..3dcf1f79 100644 --- a/src/components/post_status_form/post_status_form.vue +++ b/src/components/post_status_form/post_status_form.vue @@ -70,7 +70,10 @@ <span v-if="safeDMEnabled">{{ $t('post_status.direct_warning_to_first_only') }}</span> <span v-else>{{ $t('post_status.direct_warning_to_all') }}</span> </p> - <div class="preview-heading faint"> + <div + v-if="!disablePreview" + class="preview-heading faint" + > <a class="preview-toggle faint" @click.stop.prevent="togglePreview" From 18a1f5d62a72da45d62672043397a7471ab2c090 Mon Sep 17 00:00:00 2001 From: eugenijm <eugenijm@protonmail.com> Date: Tue, 7 Jul 2020 18:30:05 +0300 Subject: [PATCH 4/8] Add the empty chat list placeholder. Do not use full height when displaying the chat list. Remove an unsued chat action. --- src/components/chat_list/chat_list.vue | 27 +++++++++++++++++-- .../post_status_form/post_status_form.js | 2 +- src/i18n/en.json | 3 ++- src/modules/chats.js | 3 --- 4 files changed, 28 insertions(+), 7 deletions(-) diff --git a/src/components/chat_list/chat_list.vue b/src/components/chat_list/chat_list.vue index e62f58e5..fa138f16 100644 --- a/src/components/chat_list/chat_list.vue +++ b/src/components/chat_list/chat_list.vue @@ -15,7 +15,10 @@ </button> </div> <div class="panel-body"> - <div class="timeline"> + <div + v-if="sortedChatList.length > 0" + class="timeline" + > <List :items="sortedChatList"> <template slot="item" @@ -29,6 +32,12 @@ </template> </List> </div> + <div + v-else + class="emtpy-chat-list-alert" + > + <span>{{ $t('chats.empty_chat_list_placeholder') }}</span> + </div> </div> </div> </template> @@ -39,10 +48,24 @@ @import '../../_variables.scss'; .chat-list { - min-height: calc(100vh - 67px); + min-height: 25em; margin-bottom: 0; border-bottom-left-radius: 0; border-bottom-right-radius: 0; + + &::after { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } +} + +.emtpy-chat-list-alert { + padding: 3em; + font-size: 1.2em; + display: flex; + justify-content: center; + color: $fallback--text; + color: var(--faint, $fallback--text); } </style> diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js index 9e7cce0f..b0d94555 100644 --- a/src/components/post_status_form/post_status_form.js +++ b/src/components/post_status_form/post_status_form.js @@ -192,7 +192,7 @@ const PostStatusForm = { this.newStatus.poll.error }, showPreview () { - return !!this.preview || this.previewLoading + return !this.disablePreview && (!!this.preview || this.previewLoading) }, emptyStatus () { return this.newStatus.status.trim() === '' && this.newStatus.files.length === 0 diff --git a/src/i18n/en.json b/src/i18n/en.json index c9a34556..5cc75460 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -794,7 +794,8 @@ "more": "More", "delete_confirm": "Do you really want to delete this message?", "error_loading_chat": "Something went wrong when loading the chat.", - "error_sending_message": "Something went wrong when sending the message." + "error_sending_message": "Something went wrong when sending the message.", + "empty_chat_list_placeholder": "You don't have any chats yet. Start a new chat!" }, "file_type": { "audio": "Audio", diff --git a/src/modules/chats.js b/src/modules/chats.js index f868ca0c..228d6256 100644 --- a/src/modules/chats.js +++ b/src/modules/chats.js @@ -83,9 +83,6 @@ const chats = { resetChatNewMessageCount ({ commit }, value) { commit('resetChatNewMessageCount', value) }, - removeFromCurrentChatStatuses ({ commit }, { id }) { - commit('removeFromCurrentChatStatuses', id) - }, clearCurrentChat ({ rootState, commit, dispatch }, value) { commit('setCurrentChatId', { chatId: undefined }) commit('setCurrentChatFetcher', { fetcher: undefined }) From ed7310c04b3e36f1256db296784b6240023786a1 Mon Sep 17 00:00:00 2001 From: eugenijm <eugenijm@protonmail.com> Date: Tue, 7 Jul 2020 21:28:10 +0300 Subject: [PATCH 5/8] Undo the promise rejection on the json parser error in promisedRequest to keep the existing behavior in case some parts of the code rely on it and to limit the overall scope of the changes. --- src/services/api/api.service.js | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js index 5428cc2a..40ea5bd9 100644 --- a/src/services/api/api.service.js +++ b/src/services/api/api.service.js @@ -122,18 +122,13 @@ const promisedRequest = ({ method, url, params, payload, credentials, headers = } return fetch(url, options) .then((response) => { - return new Promise((resolve, reject) => { - response.json() - .then((json) => { - if (!response.ok) { - return reject(new StatusCodeError(response.status, json, { url, options }, response)) - } - return resolve(json) - }) - .catch((error) => { - return reject(new StatusCodeError(response.status, error.message, { url, options }, response)) - }) - }) + return new Promise((resolve, reject) => response.json() + .then((json) => { + if (!response.ok) { + return reject(new StatusCodeError(response.status, json, { url, options }, response)) + } + return resolve(json) + })) }) } From fc865d3a129a7d5eabf1490a82eefbdea07e3b47 Mon Sep 17 00:00:00 2001 From: eugenijm <eugenijm@protonmail.com> Date: Tue, 7 Jul 2020 21:43:46 +0300 Subject: [PATCH 6/8] Remove direct style manipulations in favor of classes --- src/components/chat_message/chat_message.js | 13 ------------- src/components/chat_message/chat_message.scss | 12 ++++++++++++ src/components/chat_message/chat_message.vue | 2 +- src/components/mobile_nav/mobile_nav.js | 4 ++-- src/components/mobile_nav/mobile_nav.vue | 2 +- 5 files changed, 16 insertions(+), 17 deletions(-) diff --git a/src/components/chat_message/chat_message.js b/src/components/chat_message/chat_message.js index 4d737e42..be4a7c89 100644 --- a/src/components/chat_message/chat_message.js +++ b/src/components/chat_message/chat_message.js @@ -60,19 +60,6 @@ const ChatMessage = { currentUser: state => state.users.currentUser, restrictedNicknames: state => state.instance.restrictedNicknames }), - ellipsisButtonWrapperStyle () { - let res = { - 'opacity': this.hovered || this.menuOpened ? '1' : '0' - } - - if (this.isCurrentUser) { - res.right = '0.4rem' - } else { - res.left = '0.4rem' - } - - return res - }, popoverMarginStyle () { if (this.isCurrentUser) { return {} diff --git a/src/components/chat_message/chat_message.scss b/src/components/chat_message/chat_message.scss index 9d7b7936..240beea4 100644 --- a/src/components/chat_message/chat_message.scss +++ b/src/components/chat_message/chat_message.scss @@ -117,6 +117,10 @@ color: var(--chatMessageIncomingText, $fallback--text); } } + + .chat-message-menu { + left: 0.4rem; + } } .outgoing { @@ -139,6 +143,14 @@ .chat-message-inner { align-items: flex-end; } + + .chat-message-menu { + right: 0.4rem; + } + } + + .visible { + opacity: 1; } } diff --git a/src/components/chat_message/chat_message.vue b/src/components/chat_message/chat_message.vue index 872ddf70..e923d694 100644 --- a/src/components/chat_message/chat_message.vue +++ b/src/components/chat_message/chat_message.vue @@ -39,7 +39,7 @@ > <div class="chat-message-menu" - :style="ellipsisButtonWrapperStyle" + :class="{ 'visible': hovered || menuOpened }" > <Popover trigger="click" diff --git a/src/components/mobile_nav/mobile_nav.js b/src/components/mobile_nav/mobile_nav.js index b27ca6f4..cc4d7e38 100644 --- a/src/components/mobile_nav/mobile_nav.js +++ b/src/components/mobile_nav/mobile_nav.js @@ -31,8 +31,8 @@ const MobileNav = { }, hideSitename () { return this.$store.state.instance.hideSitename }, sitename () { return this.$store.state.instance.name }, - navBarStyle () { - return { 'visibility': this.$route.name === 'chat' ? 'hidden' : 'visible' } + isChat () { + return this.$route.name === 'chat' } }, methods: { diff --git a/src/components/mobile_nav/mobile_nav.vue b/src/components/mobile_nav/mobile_nav.vue index 05568b90..e0620fce 100644 --- a/src/components/mobile_nav/mobile_nav.vue +++ b/src/components/mobile_nav/mobile_nav.vue @@ -3,7 +3,7 @@ <nav id="nav" class="nav-bar container" - :style="navBarStyle" + :class="{ 'mobile-hidden': isChat }" > <div class="mobile-inner-nav" From 3b2dfcaf5c402743a44eb315c519f1100d3f1006 Mon Sep 17 00:00:00 2001 From: eugenijm <eugenijm@protonmail.com> Date: Tue, 7 Jul 2020 23:33:08 +0300 Subject: [PATCH 7/8] Add the single-line prop to StatusContent and use it for chat list items --- src/components/chat/chat.scss | 1 - src/components/chat_list_item/chat_list_item.scss | 12 ++++++++++-- src/components/chat_list_item/chat_list_item.vue | 7 +++++-- src/components/chat_title/chat_title.vue | 2 ++ src/components/status_content/status_content.js | 3 ++- src/components/status_content/status_content.vue | 7 +++++++ 6 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/components/chat/chat.scss b/src/components/chat/chat.scss index 6ae7ebc9..012a1b1d 100644 --- a/src/components/chat/chat.scss +++ b/src/components/chat/chat.scss @@ -53,7 +53,6 @@ display: flex; z-index: 2; position: sticky; - display: flex; overflow: hidden; } diff --git a/src/components/chat_list_item/chat_list_item.scss b/src/components/chat_list_item/chat_list_item.scss index 3ec59ea2..617054ec 100644 --- a/src/components/chat_list_item/chat_list_item.scss +++ b/src/components/chat_list_item/chat_list_item.scss @@ -43,6 +43,7 @@ white-space: nowrap; overflow: hidden; flex-shrink: 1; + line-height: 1.4em; } .chat-preview { @@ -51,10 +52,9 @@ white-space: nowrap; text-overflow: ellipsis; margin: 0.35em 0; - height: 1.2em; - line-height: 1.2em; color: $fallback--text; color: var(--faint, $fallback--text); + width: 100%; } a { @@ -83,4 +83,12 @@ height: 1.4em; } } + + .time-wrapper { + line-height: 1.4em; + } + + .single-line { + padding-right: 1em; + } } diff --git a/src/components/chat_list_item/chat_list_item.vue b/src/components/chat_list_item/chat_list_item.vue index 640426b8..1f8ecdf6 100644 --- a/src/components/chat_list_item/chat_list_item.vue +++ b/src/components/chat_list_item/chat_list_item.vue @@ -23,7 +23,10 @@ <span class="heading-right" /> </div> <div class="chat-preview"> - <StatusContent :status="messageForStatusContent" /> + <StatusContent + :status="messageForStatusContent" + :single-line="true" + /> <div v-if="chat.unread > 0" class="badge badge-notification unread-chat-count" @@ -32,7 +35,7 @@ </div> </div> </div> - <div> + <div class="time-wrapper"> <Timeago :time="chat.updated_at" :auto-update="60" diff --git a/src/components/chat_title/chat_title.vue b/src/components/chat_title/chat_title.vue index cfd1e6d1..c375b10b 100644 --- a/src/components/chat_title/chat_title.vue +++ b/src/components/chat_title/chat_title.vue @@ -40,6 +40,8 @@ white-space: nowrap; display: inline; word-wrap: break-word; + overflow: hidden; + text-overflow: ellipsis; .emoji { width: 14px; diff --git a/src/components/status_content/status_content.js b/src/components/status_content/status_content.js index dc83d8cb..df095de3 100644 --- a/src/components/status_content/status_content.js +++ b/src/components/status_content/status_content.js @@ -14,7 +14,8 @@ const StatusContent = { 'status', 'focused', 'noHeading', - 'fullContent' + 'fullContent', + 'singleLine' ], data () { return { diff --git a/src/components/status_content/status_content.vue b/src/components/status_content/status_content.vue index 9ca2cf6d..bf8d376e 100644 --- a/src/components/status_content/status_content.vue +++ b/src/components/status_content/status_content.vue @@ -43,6 +43,7 @@ </a> <div v-if="!hideSubjectStatus" + :class="{ 'single-line': singleLine }" class="status-content media-body" @click.prevent="linkClicked" v-html="postBodyHtml" @@ -269,6 +270,12 @@ $status-margin: 0.75em; h4 { margin: 1.1em 0; } + + &.single-line { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + } } } From b756c83e8d275c0f9d210c15a319d36dca56d3c8 Mon Sep 17 00:00:00 2001 From: Eugenij <eugenijm@protonmail.com> Date: Wed, 8 Jul 2020 11:13:42 +0000 Subject: [PATCH 8/8] Apply suggestion to src/components/chat_list/chat_list.vue --- src/components/chat_list/chat_list.vue | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/components/chat_list/chat_list.vue b/src/components/chat_list/chat_list.vue index fa138f16..17e2f795 100644 --- a/src/components/chat_list/chat_list.vue +++ b/src/components/chat_list/chat_list.vue @@ -50,13 +50,6 @@ .chat-list { min-height: 25em; margin-bottom: 0; - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - - &::after { - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - } } .emtpy-chat-list-alert {