aboutsummaryrefslogtreecommitdiff
path: root/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/components')
-rw-r--r--src/components/account_actions/account_actions.js12
-rw-r--r--src/components/account_actions/account_actions.vue7
-rw-r--r--src/components/chat/chat.js333
-rw-r--r--src/components/chat/chat.scss162
-rw-r--r--src/components/chat/chat.vue100
-rw-r--r--src/components/chat/chat_layout_utils.js26
-rw-r--r--src/components/chat_list/chat_list.js37
-rw-r--r--src/components/chat_list/chat_list.vue64
-rw-r--r--src/components/chat_list_item/chat_list_item.js65
-rw-r--r--src/components/chat_list_item/chat_list_item.scss94
-rw-r--r--src/components/chat_list_item/chat_list_item.vue52
-rw-r--r--src/components/chat_message/chat_message.js96
-rw-r--r--src/components/chat_message/chat_message.scss164
-rw-r--r--src/components/chat_message/chat_message.vue99
-rw-r--r--src/components/chat_message_date/chat_message_date.vue24
-rw-r--r--src/components/chat_new/chat_new.js73
-rw-r--r--src/components/chat_new/chat_new.scss29
-rw-r--r--src/components/chat_new/chat_new.vue46
-rw-r--r--src/components/chat_panel/chat_panel.vue80
-rw-r--r--src/components/chat_title/chat_title.js26
-rw-r--r--src/components/chat_title/chat_title.vue67
-rw-r--r--src/components/emoji_input/emoji_input.js61
-rw-r--r--src/components/emoji_input/emoji_input.vue5
-rw-r--r--src/components/features_panel/features_panel.js1
-rw-r--r--src/components/features_panel/features_panel.vue3
-rw-r--r--src/components/media_upload/media_upload.js3
-rw-r--r--src/components/media_upload/media_upload.vue8
-rw-r--r--src/components/mobile_nav/mobile_nav.js5
-rw-r--r--src/components/mobile_nav/mobile_nav.vue1
-rw-r--r--src/components/mobile_post_status_button/mobile_post_status_button.js7
-rw-r--r--src/components/nav_panel/nav_panel.js20
-rw-r--r--src/components/nav_panel/nav_panel.vue11
-rw-r--r--src/components/notification/notification.js6
-rw-r--r--src/components/notifications/notifications.js9
-rw-r--r--src/components/post_status_form/post_status_form.js134
-rw-r--r--src/components/post_status_form/post_status_form.vue69
-rw-r--r--src/components/settings_modal/tabs/theme_tab/theme_tab.js6
-rw-r--r--src/components/settings_modal/tabs/theme_tab/theme_tab.vue67
-rw-r--r--src/components/side_drawer/side_drawer.js7
-rw-r--r--src/components/side_drawer/side_drawer.vue22
-rw-r--r--src/components/status_content/status_content.js5
-rw-r--r--src/components/status_content/status_content.vue9
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;
+ }
}
}