diff options
Diffstat (limited to 'src/components')
42 files changed, 1982 insertions, 133 deletions
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..9c4e5b05 --- /dev/null +++ b/src/components/chat/chat.js @@ -0,0 +1,333 @@ +import _ from 'lodash' +import { WSConnectionStatus } from '../../services/api/api.service.js' +import { mapGetters, mapState } from 'vuex' +import ChatMessage from '../chat_message/chat_message.vue' +import PostStatusForm from '../post_status_form/post_status_form.vue' +import ChatTitle from '../chat_title/chat_title.vue' +import chatService from '../../services/chat_service/chat_service.js' +import { 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, + PostStatusForm + }, + data () { + return { + jumpToBottomButtonVisible: false, + hoveredMessageChainId: undefined, + lastScrollPosition: {}, + 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.handleResize() + this.updateScrollableContainerHeight() + }) + }, + handleVisibilityChange () { + this.$nextTick(() => { + if (!document.hidden && this.bottomedOut(BOTTOMED_OUT_OFFSET)) { + this.scrollDown({ forceRead: true }) + } + }) + }, + setChatLayout () { + // This is a hacky way to adjust the global layout to the mobile chat (without modifying the rest of the app). + // This layout prevents empty spaces from being visible at the bottom + // of the chat on iOS Safari (`safe-area-inset`) when + // - the on-screen keyboard appears and the user starts typing + // - the user selects the text inside the input area + // - the user selects and deletes the text that is multiple lines long + // TODO: unify the chat layout with the global layout. + let html = document.querySelector('html') + if (html) { + html.classList.add('chat-layout') + } + + this.$nextTick(() => { + this.updateScrollableContainerHeight() + }) + }, + unsetChatLayout () { + let html = document.querySelector('html') + if (html) { + html.classList.remove('chat-layout') + } + }, + handleLayoutChange () { + this.$nextTick(() => { + this.updateScrollableContainerHeight() + this.scrollDown() + }) + }, + // Ensures the proper position of the posting form in the mobile layout (the mobile browser panel does not overlap or hide it) + updateScrollableContainerHeight () { + const header = this.$refs.header + const footer = this.$refs.footer + const inner = this.mobileLayout ? window.document.body : this.$refs.inner + this.scrollableContainerHeight = scrollableContainerHeight(inner, header, footer) + 'px' + }, + // Preserves the scroll position when OSK appears or the posting form changes its height. + handleResize (opts = {}) { + const { expand = false, delayed = false } = opts + + if (delayed) { + setTimeout(() => { + this.handleResize({ ...opts, delayed: false }) + }, SAFE_RESIZE_TIME_OFFSET) + return + } + + this.$nextTick(() => { + this.updateScrollableContainerHeight() + + const { offsetHeight = undefined } = this.lastScrollPosition + this.lastScrollPosition = getScrollPosition(this.$refs.scrollable) + + const diff = this.lastScrollPosition.offsetHeight - offsetHeight + if (diff < 0 || (!this.bottomedOut() && expand)) { + this.$nextTick(() => { + this.updateScrollableContainerHeight() + this.$refs.scrollable.scrollTo({ + top: this.$refs.scrollable.scrollTop - diff, + left: 0 + }) + }) + } + }) + }, + scrollDown (options = {}) { + const { behavior = 'auto', forceRead = false } = options + const scrollable = this.$refs.scrollable + if (!scrollable) { return } + this.$nextTick(() => { + scrollable.scrollTo({ top: scrollable.scrollHeight, left: 0, behavior }) + }) + if (forceRead || this.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.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 }) + }) + }) + + 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..012a1b1d --- /dev/null +++ b/src/components/chat/chat.scss @@ -0,0 +1,162 @@ +.chat-view { + display: flex; + 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: 0.5em 0.5em 0 0.5em; + } + + .chat-view-body { + background-color: var(--chatBg, $fallback--bg); + display: flex; + flex-direction: column; + width: 100%; + overflow: visible; + min-height: 100%; + margin: 0 0 0 0; + border-radius: 10px 10px 0 0; + border-radius: var(--panelRadius, 10px) var(--panelRadius, 10px) 0 0 ; + + &::after { + border-radius: 0; + } + } + + .scrollable-message-list { + padding: 0 0.8em; + height: 100%; + overflow-y: scroll; + overflow-x: hidden; + display: flex; + flex-direction: column; + } + + .footer { + position: sticky; + bottom: 0; + } + + .chat-view-heading { + align-items: center; + justify-content: space-between; + top: 50px; + display: flex; + z-index: 2; + position: sticky; + overflow: hidden; + } + + .go-back-button { + cursor: pointer; + margin-right: 1.4em; + + i { + display: flex; + align-items: center; + } + } + + .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; + } + + .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..2e4538c8 --- /dev/null +++ b/src/components/chat/chat.vue @@ -0,0 +1,100 @@ +<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" + :disable-preview="true" + :post-handler="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_utils.js b/src/components/chat/chat_layout_utils.js new file mode 100644 index 00000000..609dc0c9 --- /dev/null +++ b/src/components/chat/chat_layout_utils.js @@ -0,0 +1,26 @@ +// 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) => { + return inner.offsetHeight - header.clientHeight - footer.clientHeight +} 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..17e2f795 --- /dev/null +++ b/src/components/chat_list/chat_list.vue @@ -0,0 +1,64 @@ +<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 + v-if="sortedChatList.length > 0" + class="timeline" + > + <List :items="sortedChatList"> + <template + slot="item" + slot-scope="{item}" + > + <ChatListItem + :key="item.id" + :compact="false" + :chat="item" + /> + </template> + </List> + </div> + <div + v-else + class="emtpy-chat-list-alert" + > + <span>{{ $t('chats.empty_chat_list_placeholder') }}</span> + </div> + </div> + </div> +</template> + +<script src="./chat_list.js"></script> + +<style lang="scss"> +@import '../../_variables.scss'; + +.chat-list { + min-height: 25em; + margin-bottom: 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/chat_list_item/chat_list_item.js b/src/components/chat_list_item/chat_list_item.js new file mode 100644 index 00000000..b6b0519a --- /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 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' + +const ChatListItem = { + name: 'ChatListItem', + props: [ + 'chat' + ], + components: { + UserAvatar, + 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..617054ec --- /dev/null +++ b/src/components/chat_list_item/chat_list_item.scss @@ -0,0 +1,94 @@ +.chat-list-item { + display: flex; + flex-direction: row; + padding: 0.75em; + height: 5em; + overflow: hidden; + box-sizing: border-box; + cursor: pointer; + + :focus { + outline: none; + } + + &:hover { + background-color: var(--selectedPost, $fallback--lightBg); + box-shadow: 0 0 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; + } + + .name-and-account-name { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + flex-shrink: 1; + line-height: 1.4em; + } + + .chat-preview { + display: inline-flex; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + margin: 0.35em 0; + color: $fallback--text; + color: var(--faint, $fallback--text); + width: 100%; + } + + a { + color: var(--faintLink, $fallback--link); + text-decoration: none; + pointer-events: none; + } + + &:hover .animated.avatar { + canvas { + display: none; + } + img { + visibility: visible; + } + } + + .avatar.still-image { + border-radius: $fallback--avatarAltRadius; + border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius); + } + + .status-body { + img.emoji { + width: 1.4em; + 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 new file mode 100644 index 00000000..1f8ecdf6 --- /dev/null +++ b/src/components/chat_list_item/chat_list_item.vue @@ -0,0 +1,52 @@ +<template> + <div + class="chat-list-item" + @click.capture.prevent="openChat" + > + <div class="chat-list-item-left"> + <UserAvatar + :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" + :single-line="true" + /> + <div + v-if="chat.unread > 0" + class="badge badge-notification unread-chat-count" + > + {{ chat.unread }} + </div> + </div> + </div> + <div class="time-wrapper"> + <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..be4a7c89 --- /dev/null +++ b/src/components/chat_message/chat_message.js @@ -0,0 +1,96 @@ +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 + }), + 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..240beea4 --- /dev/null +++ b/src/components/chat_message/chat_message.scss @@ -0,0 +1,164 @@ +@import '../../_variables.scss'; + +.chat-message-wrapper { + &.hovered-message-chain { + .animated.avatar { + canvas { + display: none; + } + img { + visibility: visible; + } + } + } + + .chat-message-menu { + transition: opacity 0.1s; + opacity: 0; + position: absolute; + top: -0.8em; + + button { + padding-top: 0.2em; + padding-bottom: 0.2em; + } + } + + .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: 12em; + } + + .chat-message { + display: flex; + padding-bottom: 0.5em; + } + + .avatar-wrapper { + margin-right: 0.72em; + width: 32px; + } + + .link-preview, .attachments { + margin-bottom: 1em; + } + + .chat-message-inner { + display: flex; + flex-direction: column; + align-items: flex-start; + max-width: 80%; + min-width: 10em; + 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 { + position: relative; + float: right; + font-size: 0.8em; + margin: -1em 0 -0.5em 0; + font-style: italic; + opacity: 0.8; + } + + .without-attachment { + .status-content { + &::after { + margin-right: 5.4em; + 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); + } + } + + .chat-message-menu { + left: 0.4rem; + } + } + + .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-menu { + right: 0.4rem; + } + } + + .visible { + opacity: 1; + } +} + +.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..e923d694 --- /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" + :class="{ 'visible': hovered || menuOpened }" + > + <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..d023efc0 --- /dev/null +++ b/src/components/chat_new/chat_new.js @@ -0,0 +1,73 @@ +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 (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..11305444 --- /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.7rem; + } + + .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-panel { + .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-window { + overflow-y: auto; + overflow-x: hidden; + max-height: 20em; + } -.chat-message { - display: flex; - padding: 0.2em 0.5em -} + .chat-window-container { + height: 100%; + } -.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-message { + display: flex; + padding: 0.2em 0.5em } -} -.chat-input { - display: flex; - textarea { - flex: 1; - margin: 0.6em; - min-height: 3.5em; - resize: none; + .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-panel { - .title { + .chat-input { display: flex; - justify-content: space-between; + 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..e424bb1f --- /dev/null +++ b/src/components/chat_title/chat_title.js @@ -0,0 +1,26 @@ +import Vue from '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: { + UserAvatar + }, + props: [ + 'user', 'withAvatar' + ], + computed: { + title () { + return this.user ? this.user.screen_name : '' + }, + 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 new file mode 100644 index 00000000..c375b10b --- /dev/null +++ b/src/components/chat_title/chat_title.vue @@ -0,0 +1,67 @@ +<template> + <!-- eslint-disable vue/no-v-html --> + <div + class="chat-title" + :title="title" + > + <router-link + v-if="withAvatar && user" + :to="getUserProfileLink(user)" + > + <UserAvatar + :user="user" + width="23px" + height="23px" + /> + </router-link> + <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; + align-items: center; + + .username { + max-width: 100%; + text-overflow: ellipsis; + white-space: nowrap; + display: inline; + word-wrap: break-word; + overflow: hidden; + text-overflow: ellipsis; + + .emoji { + width: 14px; + height: 14px; + vertical-align: middle; + 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 7974a66d..f0123447 100644 --- a/src/components/emoji_input/emoji_input.js +++ b/src/components/emoji_input/emoji_input.js @@ -79,6 +79,20 @@ 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' + }, + newlineOnCtrlEnter: { + required: false, + type: Boolean, + default: false } }, data () { @@ -162,6 +176,11 @@ const EmojiInput = { input.elm.removeEventListener('input', this.onInput) } }, + watch: { + showSuggestions: function (newValue) { + this.$emit('shown', newValue) + } + }, methods: { triggerShowPicker () { this.showPicker = true @@ -190,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) || '' @@ -209,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, @@ -367,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') { @@ -425,15 +456,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..c8865d77 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,6 +30,8 @@ <script src="./media_upload.js" ></script> <style lang="scss"> +@import '../../_variables.scss'; + .media-upload { .label { display: inline-block; diff --git a/src/components/mobile_nav/mobile_nav.js b/src/components/mobile_nav/mobile_nav.js index c1166a0c..cc4d7e38 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 }, + isChat () { + return this.$route.name === 'chat' + } }, methods: { toggleMobileSidebar () { diff --git a/src/components/mobile_nav/mobile_nav.vue b/src/components/mobile_nav/mobile_nav.vue index 51f1d636..e0620fce 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" + :class="{ 'mobile-hidden': isChat }" > <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..b0d94555 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,23 @@ const PostStatusForm = { 'repliedUser', 'attentions', 'copyMessageScope', - 'subject' + 'subject', + 'disableSubject', + 'disableScopeSelector', + 'disableNotice', + 'disableLockWarning', + 'disablePolls', + 'disableSensitivityCheckbox', + 'disableSubmit', + 'disablePreview', + 'placeholder', + 'maxHeight', + 'postHandler', + 'preserveFocus', + 'autoFocus', + 'fileLimit', + 'submitOnEnter', + 'emojiPickerPlacement' ], components: { MediaUpload, @@ -46,10 +62,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 +91,7 @@ const PostStatusForm = { return { dropFiles: [], - submitDisabled: false, + uploadingFiles: false, error: null, posting: false, highlighted: 0, @@ -91,7 +110,8 @@ const PostStatusForm = { showDropIcon: 'hide', dropStopTimeout: null, preview: null, - previewLoading: false + previewLoading: false, + emojiInputShown: false } }, computed: { @@ -160,10 +180,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 && @@ -171,12 +192,18 @@ 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 }, - ...mapGetters(['mergedConfig']) + uploadFileLimitReached () { + return this.newStatus.files.length >= this.fileLimit + }, + ...mapGetters(['mergedConfig']), + ...mapState({ + mobileLayout: state => state.interface.mobileLayout + }) }, watch: { 'newStatus.contentType': function () { @@ -187,9 +214,15 @@ 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 (this.emptyStatus) { this.error = this.$t('post_status.empty_status_error') return @@ -211,7 +244,7 @@ const PostStatusForm = { return } - const data = await statusPoster.postStatus({ + const postingOptions = { status: newStatus.status, spoilerText: newStatus.spoilerText || null, visibility: newStatus.visibility, @@ -221,32 +254,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 postHandler = this.postHandler ? this.postHandler : statusPoster.postStatus + + postHandler(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 +342,23 @@ const PostStatusForm = { }, addMediaFile (fileInfo) { this.newStatus.files.push(fileInfo) + this.$emit('resize', { delayed: true }) }, 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.$emit('resize') + this.uploadingFiles = false }, type (fileInfo) { return fileTypeService.fileType(fileInfo.mimetype) @@ -348,7 +392,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 +411,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') this.$refs['emoji-input'].resize() return } @@ -419,8 +464,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 +527,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..3dcf1f79 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" @@ -69,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" @@ -108,7 +112,7 @@ /> </div> <EmojiInput - v-if="newStatus.spoilerText || alwaysShowSubject" + v-if="!disableSubject && (newStatus.spoilerText || alwaysShowSubject)" v-model="newStatus.spoilerText" enable-emoji-picker :suggest="emojiSuggestor" @@ -126,23 +130,28 @@ ref="emoji-input" v-model="newStatus.status" :suggest="emojiUserSuggestor" + :placement="emojiPickerPlacement" class="form-control main-input" enable-emoji-picker hide-emoji-button + :newline-on-ctrl-enter="submitOnEnter" enable-sticker-picker @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)" + @keydown.ctrl.enter="!submitOnEnter && postStatus($event, newStatus)" @input="resize" @compositionupdate="resize" @paste="paste" @@ -155,7 +164,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 +225,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 +266,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 +312,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 +346,8 @@ } .post-status-form { + position: relative; + .form-bottom { display: flex; justify-content: space-between; @@ -422,6 +439,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 @@ -547,6 +577,10 @@ padding-bottom: 1.75em; min-height: 1px; box-sizing: content-box; + + &.scrollable-form { + overflow-y: auto; + } } .main-input { @@ -609,4 +643,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, .media-upload-container > video { + 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..df095de3 100644 --- a/src/components/status_content/status_content.js +++ b/src/components/status_content/status_content.js @@ -14,11 +14,12 @@ const StatusContent = { 'status', 'focused', 'noHeading', - 'fullContent' + 'fullContent', + 'singleLine' ], 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..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" @@ -76,7 +77,7 @@ /> </a> <a - v-if="showingMore" + v-if="showingMore && !fullContent" href="#" class="status-unhider" @click.prevent="toggleShowMore" @@ -269,6 +270,12 @@ $status-margin: 0.75em; h4 { margin: 1.1em 0; } + + &.single-line { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + } } } |
