diff options
79 files changed, 3199 insertions, 242 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f54352d..49ec550c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Autocomplete domains from list of known instances - 'Bot' settings option and badge - Added profile meta data fields that can be set in profile settings +- Added option to reset avatar/banner in profile settings - Descriptions can be set on uploaded files before posting - Added status preview option to preview your statuses before posting - When a post is a reply to an unavailable post, the 'Reply to'-text has a strike-through style @@ -14,7 +14,7 @@ import MobileNav from './components/mobile_nav/mobile_nav.vue' import UserReportingModal from './components/user_reporting_modal/user_reporting_modal.vue' import PostStatusModal from './components/post_status_modal/post_status_modal.vue' import GlobalNoticeList from './components/global_notice_list/global_notice_list.vue' -import { windowWidth } from './services/window_utils/window_utils' +import { windowWidth, windowHeight } from './services/window_utils/window_utils' export default { name: 'app', @@ -127,10 +127,12 @@ export default { }, updateMobileState () { const mobileLayout = windowWidth() <= 800 + const layoutHeight = windowHeight() const changed = mobileLayout !== this.isMobileLayout if (changed) { this.$store.dispatch('setMobileLayout', mobileLayout) } + this.$store.dispatch('setLayoutHeight', layoutHeight) } } } diff --git a/src/App.scss b/src/App.scss index 6597b6f4..e2e2d079 100644 --- a/src/App.scss +++ b/src/App.scss @@ -47,6 +47,7 @@ html { } body { + overscroll-behavior-y: none; font-family: sans-serif; font-family: var(--interfaceFont, sans-serif); margin: 0; @@ -319,7 +320,7 @@ option { i[class*=icon-] { color: $fallback--icon; - color: var(--icon, $fallback--icon) + color: var(--icon, $fallback--icon); } .btn-block { @@ -928,3 +929,51 @@ nav { background-color: $fallback--fg; background-color: var(--panel, $fallback--fg); } + +.unread-chat-count { + font-size: 0.9em; + font-weight: bolder; + font-style: normal; + position: absolute; + right: 0.6rem; + padding: 0 0.3em; + min-width: 1.3rem; + min-height: 1.3rem; + max-height: 1.3rem; + line-height: 1.3rem; +} + +.chat-layout { + // Needed for smoother chat navigation in the desktop Safari (otherwise the chat layout "jumps" as the chat opens). + overflow: hidden; + height: 100%; + + // Ensures the fixed position of the mobile browser bars on scroll up / down events. + // Prevents the mobile browser bars from overlapping or hiding the message posting form. + @media all and (max-width: 800px) { + body { + height: 100%; + } + + #app { + height: 100%; + overflow: hidden; + min-height: auto; + } + + #app_bg_wrapper { + overflow: hidden; + } + + .main { + overflow: hidden; + height: 100%; + } + + #content { + padding-top: 0; + height: 100%; + overflow: visible; + } + } +} diff --git a/src/App.vue b/src/App.vue index 6386b3dc..0276c6a6 100644 --- a/src/App.vue +++ b/src/App.vue @@ -77,6 +77,7 @@ </div> </div> </nav> + <div class="app-bg-wrapper app-container-wrapper" /> <div id="content" class="container underlay" @@ -112,9 +113,7 @@ {{ $t("login.hint") }} </router-link> </div> - <transition> - <router-view /> - </transition> + <router-view /> </div> <media-modal /> </div> diff --git a/src/_variables.scss b/src/_variables.scss index 30dc3e42..9004d551 100644 --- a/src/_variables.scss +++ b/src/_variables.scss @@ -27,5 +27,6 @@ $fallback--tooltipRadius: 5px; $fallback--avatarRadius: 4px; $fallback--avatarAltRadius: 10px; $fallback--attachmentRadius: 10px; +$fallback--chatMessageRadius: 10px; $fallback--buttonShadow: 0px 0px 2px 0px rgba(0, 0, 0, 1), 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset, 0px -1px 0px 0px rgba(0, 0, 0, 0.2) inset; diff --git a/src/boot/after_store.js b/src/boot/after_store.js index 302b278c..00ca74a2 100644 --- a/src/boot/after_store.js +++ b/src/boot/after_store.js @@ -20,12 +20,20 @@ const parsedInitialResults = () => { return staticInitialResults } +const decodeUTF8Base64 = (data) => { + const rawData = atob(data) + const array = Uint8Array.from([...rawData].map((char) => char.charCodeAt(0))) + const text = new TextDecoder().decode(array) + return text +} + const preloadFetch = async (request) => { const data = parsedInitialResults() if (!data || !data[request]) { return window.fetch(request) } - const requestData = JSON.parse(atob(data[request])) + const decoded = decodeUTF8Base64(data[request]) + const requestData = JSON.parse(decoded) return { ok: true, json: () => requestData, @@ -230,6 +238,7 @@ const getNodeInfo = async ({ store }) => { store.dispatch('setInstanceOption', { name: 'mediaProxyAvailable', value: features.includes('media_proxy') }) store.dispatch('setInstanceOption', { name: 'safeDM', value: features.includes('safe_dm_mentions') }) store.dispatch('setInstanceOption', { name: 'chatAvailable', value: features.includes('chat') }) + store.dispatch('setInstanceOption', { name: 'pleromaChatMessagesAvailable', value: features.includes('pleroma_chat_messages') }) store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') }) store.dispatch('setInstanceOption', { name: 'pollsAvailable', value: features.includes('polls') }) store.dispatch('setInstanceOption', { name: 'pollLimits', value: metadata.pollLimits }) diff --git a/src/boot/routes.js b/src/boot/routes.js index f63d8adf..b5d3c631 100644 --- a/src/boot/routes.js +++ b/src/boot/routes.js @@ -6,6 +6,8 @@ import BookmarkTimeline from 'components/bookmark_timeline/bookmark_timeline.vue import ConversationPage from 'components/conversation-page/conversation-page.vue' import Interactions from 'components/interactions/interactions.vue' import DMs from 'components/dm_timeline/dm_timeline.vue' +import ChatList from 'components/chat_list/chat_list.vue' +import Chat from 'components/chat/chat.vue' import UserProfile from 'components/user_profile/user_profile.vue' import Search from 'components/search/search.vue' import Registration from 'components/registration/registration.vue' @@ -28,7 +30,7 @@ export default (store) => { } } - return [ + let routes = [ { name: 'root', path: '/', redirect: _to => { @@ -62,11 +64,20 @@ export default (store) => { { name: 'friend-requests', path: '/friend-requests', component: FollowRequests, beforeEnter: validateAuthenticatedRoute }, { name: 'notifications', path: '/:username/notifications', component: Notifications, beforeEnter: validateAuthenticatedRoute }, { name: 'login', path: '/login', component: AuthForm }, - { name: 'chat', path: '/chat', component: ChatPanel, props: () => ({ floating: false }) }, + { name: 'chat-panel', path: '/chat-panel', component: ChatPanel, props: () => ({ floating: false }) }, { name: 'oauth-callback', path: '/oauth-callback', component: OAuthCallback, props: (route) => ({ code: route.query.code }) }, { name: 'search', path: '/search', component: Search, props: (route) => ({ query: route.query.query }) }, { name: 'who-to-follow', path: '/who-to-follow', component: WhoToFollow, beforeEnter: validateAuthenticatedRoute }, { name: 'about', path: '/about', component: About }, { name: 'user-profile', path: '/(users/)?:name', component: UserProfile } ] + + if (store.state.instance.pleromaChatMessagesAvailable) { + routes = routes.concat([ + { name: 'chat', path: '/users/:username/chats/:recipient_id', component: Chat, meta: { dontScroll: false }, beforeEnter: validateAuthenticatedRoute }, + { name: 'chats', path: '/users/:username/chats', component: ChatList, meta: { dontScroll: false }, beforeEnter: validateAuthenticatedRoute } + ]) + } + + return routes } diff --git a/src/components/account_actions/account_actions.js b/src/components/account_actions/account_actions.js index 0826c275..6d345bc7 100644 --- a/src/components/account_actions/account_actions.js +++ b/src/components/account_actions/account_actions.js @@ -1,3 +1,4 @@ +import { mapState } from 'vuex' import ProgressButton from '../progress_button/progress_button.vue' import Popover from '../popover/popover.vue' @@ -27,7 +28,18 @@ const AccountActions = { }, reportUser () { this.$store.dispatch('openUserReportingModal', this.user.id) + }, + openChat () { + this.$router.push({ + name: 'chat', + params: { recipient_id: this.user.id } + }) } + }, + computed: { + ...mapState({ + pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable + }) } } diff --git a/src/components/account_actions/account_actions.vue b/src/components/account_actions/account_actions.vue index 029e7096..987e94b7 100644 --- a/src/components/account_actions/account_actions.vue +++ b/src/components/account_actions/account_actions.vue @@ -50,6 +50,13 @@ > {{ $t('user_card.report') }} </button> + <button + v-if="pleromaChatMessagesAvailable" + class="btn btn-default btn-block dropdown-item" + @click="openChat" + > + {{ $t('user_card.message') }} + </button> </div> </div> <div diff --git a/src/components/chat/chat.js b/src/components/chat/chat.js new file mode 100644 index 00000000..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..bee1ad53 --- /dev/null +++ b/src/components/chat_list_item/chat_list_item.js @@ -0,0 +1,67 @@ +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 message = this.chat.lastMessage + const isYou = message && message.account_id === this.currentUser.id + const content = message ? (this.attachmentInfo || message.content) : '' + const messagePreview = isYou ? `<i>${this.$t('chats.you')}</i> ${content}` : content + return { + summary: '', + statusnet_html: messagePreview, + text: messagePreview, + 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/checkbox/checkbox.vue b/src/components/checkbox/checkbox.vue index 03375b2f..d28c2cfd 100644 --- a/src/components/checkbox/checkbox.vue +++ b/src/components/checkbox/checkbox.vue @@ -52,7 +52,7 @@ export default { right: 0; top: 0; display: block; - content: '✔'; + content: '✓'; transition: color 200ms; width: 1.1em; height: 1.1em; 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..4613d090 100644 --- a/src/components/mobile_nav/mobile_nav.js +++ b/src/components/mobile_nav/mobile_nav.js @@ -2,6 +2,7 @@ import SideDrawer from '../side_drawer/side_drawer.vue' import Notifications from '../notifications/notifications.vue' import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils' import GestureService from '../../services/gesture_service/gesture_service' +import { mapGetters } from 'vuex' const MobileNav = { components: { @@ -30,7 +31,11 @@ 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' + }, + ...mapGetters(['unreadChatCount']) }, methods: { toggleMobileSidebar () { diff --git a/src/components/mobile_nav/mobile_nav.vue b/src/components/mobile_nav/mobile_nav.vue index 51f1d636..abd95f09 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" @@ -15,6 +16,10 @@ @click.stop.prevent="toggleMobileSidebar()" > <i class="button-icon icon-menu" /> + <div + v-if="unreadChatCount" + class="alert-dot" + /> </a> <router-link v-if="!hideSitename" 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 d9e87a1b..6ed7fc33 100644 --- a/src/components/nav_panel/nav_panel.js +++ b/src/components/nav_panel/nav_panel.js @@ -1,5 +1,5 @@ -import { mapState } from 'vuex' import { timelineNames } from '../timeline_menu/timeline_menu.js' +import { mapState, mapGetters } from 'vuex' const NavPanel = { created () { @@ -18,8 +18,10 @@ const NavPanel = { currentUser: state => state.users.currentUser, followRequestCount: state => state.api.followRequests.length, privateMode: state => state.instance.private, - federating: state => state.instance.federating - }) + federating: state => state.instance.federating, + pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable + }), + ...mapGetters(['unreadChatCount']) } } diff --git a/src/components/nav_panel/nav_panel.vue b/src/components/nav_panel/nav_panel.vue index dd6a433b..f8459fd1 100644 --- a/src/components/nav_panel/nav_panel.vue +++ b/src/components/nav_panel/nav_panel.vue @@ -15,6 +15,17 @@ <i class="button-icon icon-bell-alt" /> {{ $t("nav.interactions") }} </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/notifications/notifications.scss b/src/components/notifications/notifications.scss index 20797cf9..b1a3ad70 100644 --- a/src/components/notifications/notifications.scss +++ b/src/components/notifications/notifications.scss @@ -118,6 +118,11 @@ flex: 1; padding-left: 0.8em; min-width: 0; + + .timeago { + min-width: 3em; + text-align: right; + } } .emoji-reaction-emoji { diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js index 18f02eba..1c0accac 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.disableSubmit) { 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/profile_tab.js b/src/components/settings_modal/tabs/profile_tab.js index e6db802d..bd6bef6a 100644 --- a/src/components/settings_modal/tabs/profile_tab.js +++ b/src/components/settings_modal/tabs/profile_tab.js @@ -77,6 +77,33 @@ const ProfileTab = { }, maxFields () { return this.fieldsLimits ? this.fieldsLimits.maxFields : 0 + }, + defaultAvatar () { + return this.$store.state.instance.server + this.$store.state.instance.defaultAvatar + }, + defaultBanner () { + return this.$store.state.instance.server + this.$store.state.instance.defaultBanner + }, + isDefaultAvatar () { + const baseAvatar = this.$store.state.instance.defaultAvatar + return !(this.$store.state.users.currentUser.profile_image_url) || + this.$store.state.users.currentUser.profile_image_url.includes(baseAvatar) + }, + isDefaultBanner () { + const baseBanner = this.$store.state.instance.defaultBanner + return !(this.$store.state.users.currentUser.cover_photo) || + this.$store.state.users.currentUser.cover_photo.includes(baseBanner) + }, + isDefaultBackground () { + return !(this.$store.state.users.currentUser.background_image) + }, + avatarImgSrc () { + const src = this.$store.state.users.currentUser.profile_image_url_original + return (!src) ? this.defaultAvatar : src + }, + bannerImgSrc () { + const src = this.$store.state.users.currentUser.cover_photo + return (!src) ? this.defaultBanner : src } }, methods: { @@ -150,11 +177,29 @@ const ProfileTab = { } reader.readAsDataURL(file) }, + resetAvatar () { + const confirmed = window.confirm(this.$t('settings.reset_avatar_confirm')) + if (confirmed) { + this.submitAvatar(undefined, '') + } + }, + resetBanner () { + const confirmed = window.confirm(this.$t('settings.reset_banner_confirm')) + if (confirmed) { + this.submitBanner('') + } + }, + resetBackground () { + const confirmed = window.confirm(this.$t('settings.reset_background_confirm')) + if (confirmed) { + this.submitBackground('') + } + }, submitAvatar (cropper, file) { const that = this return new Promise((resolve, reject) => { function updateAvatar (avatar) { - that.$store.state.api.backendInteractor.updateAvatar({ avatar }) + that.$store.state.api.backendInteractor.updateProfileImages({ avatar }) .then((user) => { that.$store.commit('addNewUsers', [user]) that.$store.commit('setCurrentUser', user) @@ -172,11 +217,11 @@ const ProfileTab = { } }) }, - submitBanner () { - if (!this.bannerPreview) { return } + submitBanner (banner) { + if (!this.bannerPreview && banner !== '') { return } this.bannerUploading = true - this.$store.state.api.backendInteractor.updateBanner({ banner: this.banner }) + this.$store.state.api.backendInteractor.updateProfileImages({ banner }) .then((user) => { this.$store.commit('addNewUsers', [user]) this.$store.commit('setCurrentUser', user) @@ -187,11 +232,11 @@ const ProfileTab = { }) .then(() => { this.bannerUploading = false }) }, - submitBg () { - if (!this.backgroundPreview) { return } - let background = this.background + submitBackground (background) { + if (!this.backgroundPreview && background !== '') { return } + this.backgroundUploading = true - this.$store.state.api.backendInteractor.updateBg({ background }).then((data) => { + this.$store.state.api.backendInteractor.updateProfileImages({ background }).then((data) => { if (!data.error) { this.$store.commit('addNewUsers', [data]) this.$store.commit('setCurrentUser', data) diff --git a/src/components/settings_modal/tabs/profile_tab.scss b/src/components/settings_modal/tabs/profile_tab.scss index b3dcf42c..e14cf054 100644 --- a/src/components/settings_modal/tabs/profile_tab.scss +++ b/src/components/settings_modal/tabs/profile_tab.scss @@ -13,8 +13,14 @@ height: auto; } - .banner { + .banner-background-preview { max-width: 100%; + width: 300px; + position: relative; + + img { + width: 100%; + } } .uploading { @@ -26,18 +32,40 @@ width: 100%; } - .bg { - max-width: 100%; + .current-avatar-container { + position: relative; + width: 150px; + height: 150px; } .current-avatar { display: block; - width: 150px; - height: 150px; + width: 100%; + height: 100%; border-radius: $fallback--avatarRadius; border-radius: var(--avatarRadius, $fallback--avatarRadius); } + .reset-button { + position: absolute; + top: 0.2em; + right: 0.2em; + border-radius: $fallback--tooltipRadius; + border-radius: var(--tooltipRadius, $fallback--tooltipRadius); + background-color: rgba(0, 0, 0, 0.6); + opacity: 0.7; + color: white; + width: 1.5em; + height: 1.5em; + text-align: center; + line-height: 1.5em; + font-size: 1.5em; + cursor: pointer; + &:hover { + opacity: 1; + } + } + .oauth-tokens { width: 100%; @@ -86,6 +114,7 @@ &>.emoji-input { flex: 1 1 auto; margin: 0 .2em .5em; + min-width: 0; } &>.icon-container { diff --git a/src/components/settings_modal/tabs/profile_tab.vue b/src/components/settings_modal/tabs/profile_tab.vue index 0f9210a6..cf88c4e4 100644 --- a/src/components/settings_modal/tabs/profile_tab.vue +++ b/src/components/settings_modal/tabs/profile_tab.vue @@ -161,11 +161,19 @@ <p class="visibility-notice"> {{ $t('settings.avatar_size_instruction') }} </p> - <p>{{ $t('settings.current_avatar') }}</p> - <img - :src="user.profile_image_url_original" - class="current-avatar" - > + <div class="current-avatar-container"> + <img + :src="user.profile_image_url_original" + class="current-avatar" + > + <i + v-if="!isDefaultAvatar && pickAvatarBtnVisible" + :title="$t('settings.reset_avatar')" + class="reset-button icon-cancel" + type="button" + @click="resetAvatar" + /> + </div> <p>{{ $t('settings.set_new_avatar') }}</p> <button v-show="pickAvatarBtnVisible" @@ -184,15 +192,20 @@ </div> <div class="setting-item"> <h2>{{ $t('settings.profile_banner') }}</h2> - <p>{{ $t('settings.current_profile_banner') }}</p> - <img - :src="user.cover_photo" - class="banner" - > + <div class="banner-background-preview"> + <img :src="user.cover_photo"> + <i + v-if="!isDefaultBanner" + :title="$t('settings.reset_profile_banner')" + class="reset-button icon-cancel" + type="button" + @click="resetBanner" + /> + </div> <p>{{ $t('settings.set_new_profile_banner') }}</p> <img v-if="bannerPreview" - class="banner" + class="banner-background-preview" :src="bannerPreview" > <div> @@ -208,7 +221,7 @@ <button v-else-if="bannerPreview" class="btn btn-default" - @click="submitBanner" + @click="submitBanner(banner)" > {{ $t('general.submit') }} </button> @@ -225,10 +238,20 @@ </div> <div class="setting-item"> <h2>{{ $t('settings.profile_background') }}</h2> + <div class="banner-background-preview"> + <img :src="user.background_image"> + <i + v-if="!isDefaultBackground" + :title="$t('settings.reset_profile_background')" + class="reset-button icon-cancel" + type="button" + @click="resetBackground" + /> + </div> <p>{{ $t('settings.set_new_profile_background') }}</p> <img v-if="backgroundPreview" - class="bg" + class="banner-background-preview" :src="backgroundPreview" > <div> @@ -244,7 +267,7 @@ <button v-else-if="backgroundPreview" class="btn btn-default" - @click="submitBg" + @click="submitBackground(background)" > {{ $t('general.submit') }} </button> 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 b955729c..e13c66db 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' @@ -50,7 +51,11 @@ const SideDrawer = { }, timelinesRoute () { return this.currentUser ? 'friends' : 'public-timeline' - } + }, + ...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 5541b2cf..4a4e7610 100644 --- a/src/components/side_drawer/side_drawer.vue +++ b/src/components/side_drawer/side_drawer.vue @@ -47,6 +47,23 @@ <i class="button-icon icon-home-2" /> {{ $t("nav.timelines") }} </router-link> </li> + <li + v-if="currentUser && pleromaChatMessagesAvailable" + @click="toggleDrawer" + > + <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> </ul> <ul v-if="currentUser"> <li @click="toggleDrawer"> @@ -69,11 +86,27 @@ </router-link> </li> <li +<<<<<<< HEAD v-if="chat" @click="toggleDrawer" > <router-link :to="{ name: 'chat' }"> <i class="button-icon icon-chat" /> {{ $t("nav.chat") }} +======= + v-if="currentUser || !privateMode" + @click="toggleDrawer" + > + <router-link to="/main/public"> + <i class="button-icon icon-users" /> {{ $t("nav.public_tl") }} + </router-link> + </li> + <li + v-if="federating && (currentUser || !privateMode)" + @click="toggleDrawer" + > + <router-link to="/main/all"> + <i class="button-icon icon-globe" /> {{ $t("nav.twkn") }} +>>>>>>> develop </router-link> </li> </ul> 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; + } } } diff --git a/src/components/user_avatar/user_avatar.js b/src/components/user_avatar/user_avatar.js index 4adf8211..94653004 100644 --- a/src/components/user_avatar/user_avatar.js +++ b/src/components/user_avatar/user_avatar.js @@ -8,26 +8,20 @@ const UserAvatar = { ], data () { return { - showPlaceholder: false + showPlaceholder: false, + defaultAvatar: `${this.$store.state.instance.server + this.$store.state.instance.defaultAvatar}` } }, components: { StillImage }, - computed: { - imgSrc () { - return this.showPlaceholder ? '/images/avi.png' : this.user.profile_image_url_original - } - }, methods: { + imgSrc (src) { + return (!src || this.showPlaceholder) ? this.defaultAvatar : src + }, imageLoadError () { this.showPlaceholder = true } - }, - watch: { - src () { - this.showPlaceholder = false - } } } diff --git a/src/components/user_avatar/user_avatar.vue b/src/components/user_avatar/user_avatar.vue index 9ffb28d8..3545b801 100644 --- a/src/components/user_avatar/user_avatar.vue +++ b/src/components/user_avatar/user_avatar.vue @@ -3,7 +3,7 @@ class="avatar" :alt="user.screen_name" :title="user.screen_name" - :src="imgSrc" + :src="imgSrc(user.profile_image_url_original)" :class="{ 'avatar-compact': compact, 'better-shadow': betterShadow }" :image-load-error="imageLoadError" /> diff --git a/src/components/who_to_follow_panel/who_to_follow_panel.js b/src/components/who_to_follow_panel/who_to_follow_panel.js index dcb56106..818e8bd5 100644 --- a/src/components/who_to_follow_panel/who_to_follow_panel.js +++ b/src/components/who_to_follow_panel/who_to_follow_panel.js @@ -7,7 +7,7 @@ function showWhoToFollow (panel, reply) { panel.usersToFollow.forEach((toFollow, index) => { let user = shuffled[index] - let img = user.avatar || '/images/avi.png' + let img = user.avatar || this.$store.state.instance.defaultAvatar let name = user.acct toFollow.img = img @@ -38,13 +38,7 @@ function getWhoToFollow (panel) { const WhoToFollowPanel = { data: () => ({ - usersToFollow: new Array(3).fill().map(x => ( - { - img: '/images/avi.png', - name: '', - id: 0 - } - )) + usersToFollow: [] }), computed: { user: function () { @@ -68,6 +62,13 @@ const WhoToFollowPanel = { }, mounted: function () { + this.usersToFollow = new Array(3).fill().map(x => ( + { + img: this.$store.state.instance.defaultAvatar, + name: '', + id: 0 + } + )) if (this.suggestionsEnabled) { getWhoToFollow(this) } diff --git a/src/hocs/with_load_more/with_load_more.scss b/src/hocs/with_load_more/with_load_more.scss index 4cefe2be..1a26eb8d 100644 --- a/src/hocs/with_load_more/with_load_more.scss +++ b/src/hocs/with_load_more/with_load_more.scss @@ -12,5 +12,9 @@ .error { font-size: 14px; } + + a { + cursor: pointer; + } } } diff --git a/src/i18n/en.json b/src/i18n/en.json index 254701b4..b62ca3b3 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -44,6 +44,7 @@ }, "features_panel": { "chat": "Chat", + "pleroma_chat_messages": "Pleroma Chat", "gopher": "Gopher", "media_proxy": "Media proxy", "scope_options": "Scope options", @@ -125,7 +126,8 @@ "search": "Search", "who_to_follow": "Who to follow", "preferences": "Preferences", - "timelines": "Timelines" + "timelines": "Timelines", + "chats": "Chats" }, "notifications": { "broken_favorite": "Unknown status, searching for it…", @@ -288,12 +290,11 @@ "change_password": "Change Password", "change_password_error": "There was an issue changing your password.", "changed_password": "Password changed successfully!", + "chatMessageRadius": "Chat message", "collapse_subject": "Collapse posts with subjects", "composing": "Composing", "confirm_new_password": "Confirm new password", - "current_avatar": "Your current avatar", "current_password": "Current password", - "current_profile_banner": "Your current profile banner", "mutes_and_blocks": "Mutes and Blocks", "data_import_export_tab": "Data Import / Export", "default_vis": "Default visibility scope", @@ -400,6 +401,12 @@ "set_new_avatar": "Set new avatar", "set_new_profile_background": "Set new profile background", "set_new_profile_banner": "Set new profile banner", + "reset_avatar": "Reset avatar", + "reset_profile_background": "Reset profile background", + "reset_profile_banner": "Reset profile banner", + "reset_avatar_confirm": "Do you really want to reset the avatar?", + "reset_banner_confirm": "Do you really want to reset the banner?", + "reset_background_confirm": "Do you really want to reset the background?", "settings": "Settings", "subject_input_always_show": "Always show subject field", "subject_line_behavior": "Copy subject when replying", @@ -515,7 +522,12 @@ "selectedMenu": "Selected menu item", "disabled": "Disabled", "toggled": "Toggled", - "tabs": "Tabs" + "tabs": "Tabs", + "chat": { + "incoming": "Incoming", + "outgoing": "Outgoing", + "border": "Border" + } }, "radii": { "_tab_label": "Roundness" @@ -674,6 +686,7 @@ "its_you": "It's you!", "media": "Media", "mention": "Mention", + "message": "Message", "mute": "Mute", "muted": "Muted", "per_day": "per day", @@ -772,5 +785,27 @@ "password_reset_disabled": "Password reset is disabled. Please contact your instance administrator.", "password_reset_required": "You must reset your password to log in.", "password_reset_required_but_mailer_is_disabled": "You must reset your password, but password reset is disabled. Please contact your instance administrator." + }, + "chats": { + "you": "You:", + "message_user": "Message {nickname}", + "delete": "Delete", + "chats": "Chats", + "new": "New Chat", + "empty_message_error": "Cannot post empty message", + "more": "More", + "delete_confirm": "Do you really want to delete this message?", + "error_loading_chat": "Something went wrong when loading the chat.", + "error_sending_message": "Something went wrong when sending the message.", + "empty_chat_list_placeholder": "You don't have any chats yet. Start a new chat!" + }, + "file_type": { + "audio": "Audio", + "video": "Video", + "image": "Image", + "file": "File" + }, + "display_date": { + "today": "Today" } } diff --git a/src/i18n/fi.json b/src/i18n/fi.json index f3c2e528..e7667d54 100644 --- a/src/i18n/fi.json +++ b/src/i18n/fi.json @@ -28,7 +28,12 @@ "disable": "Poista käytöstä", "confirm": "Hyväksy", "verify": "Varmenna", - "enable": "Ota käyttöön" + "enable": "Ota käyttöön", + "loading": "Ladataan…", + "error_retry": "Yritä uudelleen", + "retry": "Yritä uudelleen", + "close": "Sulje", + "peek": "Kurkkaa" }, "login": { "login": "Kirjaudu sisään", @@ -63,7 +68,8 @@ "who_to_follow": "Seurausehdotukset", "preferences": "Asetukset", "administration": "Ylläpito", - "search": "Haku" + "search": "Haku", + "bookmarks": "Kirjanmerkit" }, "notifications": { "broken_favorite": "Viestiä ei löydetty…", @@ -126,7 +132,12 @@ "public": "Tämä viesti näkyy kaikille", "private": "Tämä viesti näkyy vain sinun seuraajillesi", "unlisted": "Tämä viesti ei näy Julkisella Aikajanalla tai Koko Tunnettu Verkosto -aikajanalla" - } + }, + "preview": "Esikatselu", + "preview_empty": "Tyhjä", + "empty_status_error": "Tyhjää viestiä ilman tiedostoja ei voi lähettää", + "media_description": "Tiedoston kuvaus", + "media_description_error": "Tiedostojen päivitys epäonnistui, yritä uudelleen" }, "registration": { "bio": "Kuvaus", @@ -175,7 +186,7 @@ "data_import_export_tab": "Tietojen tuonti / vienti", "default_vis": "Oletusnäkyvyysrajaus", "delete_account": "Poista tili", - "delete_account_description": "Poista tilisi ja viestisi pysyvästi.", + "delete_account_description": "Poista tietosi ja lukitse tili pysyvästi.", "delete_account_error": "Virhe poistaessa tiliäsi. Jos virhe jatkuu, ota yhteyttä palvelimesi ylläpitoon.", "delete_account_instructions": "Syötä salasanasi vahvistaaksesi tilin poiston.", "emoji_reactions_on_timeline": "Näytä emojireaktiot aikajanalla", @@ -329,7 +340,7 @@ "post_status_content_type": "Uuden viestin sisällön muoto", "user_mutes": "Käyttäjät", "useStreamingApiWarning": "(Kokeellinen)", - "type_domains_to_mute": "Syötä mykistettäviä sivustoja", + "type_domains_to_mute": "Etsi mykistettäviä sivustoja", "upload_a_photo": "Lataa kuva", "fun": "Hupi", "greentext": "Meeminuolet", @@ -490,7 +501,21 @@ "title": "Versio", "backend_version": "Palvelimen versio", "frontend_version": "Käyttöliittymän versio" - } + }, + "reset_profile_background": "Nollaa taustakuva", + "reset_background_confirm": "Haluatko todella nollata taustakuvan?", + "mutes_and_blocks": "Mykistykset ja Estot", + "bot": "Tämä on bottitili", + "profile_fields": { + "label": "Profiilin metatiedot", + "add_field": "Lisää kenttä", + "name": "Nimi", + "value": "Sisältö" + }, + "reset_avatar": "Nollaa profiilikuva", + "reset_profile_banner": "Nollaa profiilin tausta", + "reset_avatar_confirm": "Haluatko todella nollata profiilikuvan?", + "reset_banner_confirm": "Haluatko todella nollata profiilin taustan?" }, "time": { "day": "{0} päivä", @@ -536,7 +561,8 @@ "show_new": "Näytä uudet", "up_to_date": "Ajantasalla", "no_more_statuses": "Ei enempää viestejä", - "no_statuses": "Ei viestejä" + "no_statuses": "Ei viestejä", + "reload": "Päivitä" }, "status": { "favorites": "Tykkäykset", @@ -551,7 +577,15 @@ "mute_conversation": "Mykistä keskustelu", "unmute_conversation": "Poista mykistys", "status_unavailable": "Viesti ei saatavissa", - "copy_link": "Kopioi linkki" + "copy_link": "Kopioi linkki", + "bookmark": "Lisää kirjanmerkkeihin", + "unbookmark": "Poista kirjanmerkeistä", + "thread_muted": "Keskustelu mykistetty", + "thread_muted_and_words": ", sisältää sanat:", + "show_full_subject": "Näytä koko otsikko", + "hide_full_subject": "Piilota koko otsikko", + "show_content": "Näytä sisältö", + "hide_content": "Piilota sisältö" }, "user_card": { "approve": "Hyväksy", @@ -561,7 +595,7 @@ "follow": "Seuraa", "follow_sent": "Pyyntö lähetetty!", "follow_progress": "Pyydetään…", - "follow_again": "Lähetä pyyntö uudestaan", + "follow_again": "Lähetä pyyntö uudestaan?", "follow_unfollow": "Älä seuraa", "followees": "Seuraa", "followers": "Seuraajat", @@ -601,7 +635,7 @@ "subscribe": "Tilaa", "unsubscribe": "Poista tilaus", "unblock": "Poista esto", - "unblock_progress": "Postetaan estoa…", + "unblock_progress": "Poistetaan estoa…", "unmute": "Poista mykistys", "unmute_progress": "Poistetaan mykistystä…", "mute_progress": "Mykistetään…", @@ -625,7 +659,8 @@ "user_settings": "Käyttäjäasetukset", "add_reaction": "Lisää Reaktio", "accept_follow_request": "Hyväksy seurauspyyntö", - "reject_follow_request": "Hylkää seurauspyyntö" + "reject_follow_request": "Hylkää seurauspyyntö", + "bookmark": "Kirjanmerkki" }, "upload": { "error": { @@ -675,7 +710,7 @@ "mute": "Mykistä", "unmute": "Poista mykistys", "mute_progress": "Mykistetään…", - "unmute_progress": "Poistetaan mykistyst…" + "unmute_progress": "Poistetaan mykistystä…" }, "exporter": { "export": "Vie", @@ -743,5 +778,8 @@ "people_talking": "{0} käyttäjää puhuvat", "person_talking": "{0} käyttäjä puhuu", "no_results": "Ei tuloksia" + }, + "errors": { + "storage_unavailable": "Pleroma ei voinut käyttää selaimen muistia. Kirjautumisesi ja paikalliset asetukset eivät tallennu ja saatat kohdata odottamattomia ongelmia. Yritä sallia evästeet." } } diff --git a/src/i18n/it.json b/src/i18n/it.json index ed78e656..24520c09 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -35,7 +35,8 @@ "search": "Ricerca", "who_to_follow": "Chi seguire", "preferences": "Preferenze", - "bookmarks": "Segnalibri" + "bookmarks": "Segnalibri", + "chats": "Conversazioni" }, "notifications": { "followed_you": "ti segue", @@ -85,7 +86,7 @@ "change_password": "Cambia password", "change_password_error": "C'è stato un problema durante il cambiamento della password.", "changed_password": "Password cambiata correttamente!", - "collapse_subject": "Ripiega messaggi con Oggetto", + "collapse_subject": "Ripiega messaggi con oggetto", "confirm_new_password": "Conferma la nuova password", "current_password": "La tua password attuale", "data_import_export_tab": "Importa o esporta dati", @@ -257,7 +258,12 @@ "panel_header": "Titolo pannello", "badge_notification": "Notifica", "popover": "Suggerimenti, menù, sbalzi", - "toggled": "Scambiato" + "toggled": "Scambiato", + "chat": { + "border": "Bordo", + "outgoing": "Inviati", + "incoming": "Ricevuti" + } }, "common_colors": { "rgbo": "Icone, accenti, medaglie", @@ -398,7 +404,14 @@ "frontend_version": "Versione interfaccia", "backend_version": "Versione backend", "title": "Versione" - } + }, + "reset_avatar": "Azzera icona", + "reset_profile_background": "Azzera sfondo profilo", + "reset_profile_banner": "Azzera stendardo profilo", + "reset_avatar_confirm": "Vuoi veramente azzerare l'icona?", + "reset_banner_confirm": "Vuoi veramente azzerare lo stendardo?", + "reset_background_confirm": "Vuoi veramente azzerare lo sfondo?", + "chatMessageRadius": "Messaggi istantanei" }, "timeline": { "error_fetching": "Errore nell'aggiornamento", @@ -427,7 +440,47 @@ "block": "Blocca", "blocked": "Bloccato!", "deny": "Nega", - "remote_follow": "Segui da remoto" + "remote_follow": "Segui da remoto", + "admin_menu": { + "delete_user_confirmation": "Ne sei completamente sicuro? Quest'azione non può essere annullata.", + "delete_user": "Elimina utente", + "quarantine": "I messaggi non arriveranno alle altre stanze", + "disable_any_subscription": "Rendi utente non seguibile", + "disable_remote_subscription": "Blocca i tentativi di seguirlo da altre stanze", + "sandbox": "Rendi tutti i messaggi solo per seguaci", + "force_unlisted": "Rendi tutti i messaggi invisibili", + "strip_media": "Rimuovi ogni allegato ai messaggi", + "force_nsfw": "Oscura tutti i messaggi", + "delete_account": "Elimina profilo", + "deactivate_account": "Disattiva profilo", + "activate_account": "Attiva profilo", + "revoke_moderator": "Divesti Moderatore", + "grant_moderator": "Crea Moderatore", + "revoke_admin": "Divesti Amministratore", + "grant_admin": "Crea Amministratore", + "moderation": "Moderazione" + }, + "show_repeats": "Mostra condivisioni", + "hide_repeats": "Nascondi condivisioni", + "mute_progress": "Zittisco…", + "unmute_progress": "Riabilito…", + "unmute": "Riabilita", + "block_progress": "Blocco…", + "unblock_progress": "Sblocco…", + "unblock": "Sblocca", + "unsubscribe": "Disdici", + "subscribe": "Abbònati", + "report": "Segnala", + "mention": "Menzioni", + "media": "Media", + "its_you": "Sei tu!", + "hidden": "Nascosto", + "follow_unfollow": "Disconosci", + "follow_again": "Reinvio richiesta?", + "follow_progress": "Richiedo…", + "follow_sent": "Richiesta inviata!", + "favorites": "Preferiti", + "message": "Contatta" }, "chat": { "title": "Chat" @@ -439,7 +492,8 @@ "scope_options": "Opzioni visibilità ", "text_limit": "Lunghezza massima", "title": "Caratteristiche", - "who_to_follow": "Chi seguire" + "who_to_follow": "Chi seguire", + "pleroma_chat_messages": "Chiacchiere" }, "finder": { "error_fetching_user": "Errore nel recupero dell'utente", @@ -493,7 +547,9 @@ "new_status": "Nuovo messaggio", "empty_status_error": "Non puoi pubblicare messaggi vuoti senza allegati", "preview_empty": "Vuoto", - "preview": "Anteprima" + "preview": "Anteprima", + "media_description_error": "Allegati non caricati, riprova", + "media_description": "Descrizione allegati" }, "registration": { "bio": "Introduzione", @@ -517,7 +573,9 @@ "captcha": "CAPTCHA" }, "user_profile": { - "timeline_title": "Sequenza dell'Utente" + "timeline_title": "Sequenza dell'Utente", + "profile_loading_error": "Spiacente, c'è stato un errore nel caricamento del profilo.", + "profile_does_not_exist": "Spiacente, questo profilo non esiste." }, "who_to_follow": { "more": "Altro", @@ -626,7 +684,22 @@ "pin": "Intesta al profilo", "delete": "Elimina messaggio", "repeats": "Condivisi", - "favorites": "Preferiti" + "favorites": "Preferiti", + "hide_content": "Nascondi contenuti", + "show_content": "Mostra contenuti", + "hide_full_subject": "Nascondi intero oggetto", + "show_full_subject": "Mostra intero oggetto", + "thread_muted_and_words": ", contiene:", + "thread_muted": "Discussione zittita", + "copy_link": "Copia collegamento", + "status_unavailable": "Messaggio non disponibile", + "unmute_conversation": "Riabilita conversazione", + "mute_conversation": "Zittisci conversazione", + "replies_list": "Risposte:", + "reply_to": "Rispondi a", + "delete_confirm": "Vuoi veramente eliminare questo messaggio?", + "unbookmark": "Rimuovi segnalibro", + "bookmark": "Aggiungi segnalibro" }, "time": { "years_short": "{0}a", @@ -661,5 +734,80 @@ "day_short": "{0}g", "days": "{0} giorni", "day": "{0} giorno" + }, + "user_reporting": { + "title": "Segnalo {0}", + "additional_comments": "Osservazioni accessorie", + "generic_error": "C'è stato un errore nell'elaborazione della tua richiesta.", + "submit": "Invia", + "forward_to": "Inoltra a {0}", + "forward_description": "Il profilo appartiene ad un'altra stanza. Inviare la segnalazione anche a quella?", + "add_comment_description": "La segnalazione sarà inviata ai moderatori della tua stanza. Puoi motivarla qui sotto:" + }, + "password_reset": { + "password_reset_required_but_mailer_is_disabled": "Devi reimpostare la tua password, ma non puoi farlo. Contatta il tuo amministratore.", + "password_reset_required": "Devi reimpostare la tua password per poter continuare.", + "password_reset_disabled": "Non puoi azzerare la tua password. Contatta il tuo amministratore.", + "too_many_requests": "Hai raggiunto il numero massimo di tentativi, riprova più tardi.", + "not_found": "Non ho trovato questa email o nome utente.", + "return_home": "Torna alla pagina principale", + "check_email": "Controlla la tua posta elettronica.", + "placeholder": "La tua email o nome utente", + "instruction": "Inserisci il tuo indirizzo email o il tuo nome utente. Ti invieremo un collegamento per reimpostare la tua password.", + "password_reset": "Azzera password", + "forgot_password": "Password dimenticata?" + }, + "search": { + "no_results": "Nessun risultato", + "people_talking": "{count} partecipanti", + "person_talking": "{count} partecipante", + "hashtags": "Etichette", + "people": "Utenti" + }, + "upload": { + "file_size_units": { + "TiB": "TiB", + "GiB": "GiB", + "MiB": "MiB", + "KiB": "KiB", + "B": "B" + }, + "error": { + "default": "Riprova in seguito", + "file_too_big": "File troppo pesante [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]", + "base": "Caricamento fallito." + } + }, + "tool_tip": { + "bookmark": "Aggiungi segnalibro", + "reject_follow_request": "Rifiuta seguace", + "accept_follow_request": "Accetta seguace", + "user_settings": "Impostazioni utente", + "add_reaction": "Reagisci", + "favorite": "Gradisci", + "reply": "Rispondi", + "repeat": "Ripeti", + "media_upload": "Carica allegati" + }, + "display_date": { + "today": "Oggi" + }, + "file_type": { + "file": "File", + "image": "Immagine", + "video": "Video", + "audio": "Audio" + }, + "chats": { + "empty_chat_list_placeholder": "Non hai conversazioni. Contatta qualcuno!", + "error_sending_message": "Errore. Il messaggio non è stato inviato.", + "error_loading_chat": "Errore. La conversazione non è stata caricata.", + "delete_confirm": "Vuoi veramente eliminare questo messaggio?", + "more": "Altro", + "empty_message_error": "Non puoi inviare messaggi vuoti", + "new": "Nuova conversazione", + "chats": "Conversazioni", + "delete": "Elimina", + "message_user": "Contatta {nickname}" } } diff --git a/src/main.js b/src/main.js index 5bddc76e..0a898022 100644 --- a/src/main.js +++ b/src/main.js @@ -19,6 +19,7 @@ import oauthTokensModule from './modules/oauth_tokens.js' import reportsModule from './modules/reports.js' import pollsModule from './modules/polls.js' import postStatusModule from './modules/postStatus.js' +import chatsModule from './modules/chats.js' import VueI18n from 'vue-i18n' @@ -91,7 +92,8 @@ const persistedStateOptions = { oauthTokens: oauthTokensModule, reports: reportsModule, polls: pollsModule, - postStatus: postStatusModule + postStatus: postStatusModule, + chats: chatsModule }, plugins, strict: false // Socket modifies itself, let's ignore this for now. diff --git a/src/modules/api.js b/src/modules/api.js index 04ef6ab4..68402602 100644 --- a/src/modules/api.js +++ b/src/modules/api.js @@ -1,4 +1,5 @@ import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js' +import { WSConnectionStatus } from '../services/api/api.service.js' import { Socket } from 'phoenix' const api = { @@ -7,6 +8,7 @@ const api = { fetchers: {}, socket: null, mastoUserSocket: null, + mastoUserSocketStatus: null, followRequests: [] }, mutations: { @@ -28,6 +30,9 @@ const api = { }, setFollowRequests (state, value) { state.followRequests = value + }, + setMastoUserSocketStatus (state, value) { + state.mastoUserSocketStatus = value } }, actions: { @@ -47,7 +52,7 @@ const api = { startMastoUserSocket (store) { return new Promise((resolve, reject) => { try { - const { state, dispatch, rootState } = store + const { state, commit, dispatch, rootState } = store const timelineData = rootState.statuses.timelines.friends state.mastoUserSocket = state.backendInteractor.startUserSocket({ store }) state.mastoUserSocket.addEventListener( @@ -66,11 +71,22 @@ const api = { showImmediately: timelineData.visibleStatuses.length === 0, timeline: 'friends' }) + } else if (message.event === 'pleroma:chat_update') { + dispatch('addChatMessages', { + chatId: message.chatUpdate.id, + messages: [message.chatUpdate.lastMessage] + }) + dispatch('updateChat', { chat: message.chatUpdate }) } } ) + state.mastoUserSocket.addEventListener('open', () => { + commit('setMastoUserSocketStatus', WSConnectionStatus.JOINED) + }) state.mastoUserSocket.addEventListener('error', ({ detail: error }) => { console.error('Error in MastoAPI websocket:', error) + commit('setMastoUserSocketStatus', WSConnectionStatus.ERROR) + dispatch('clearOpenedChats') }) state.mastoUserSocket.addEventListener('close', ({ detail: closeEvent }) => { const ignoreCodes = new Set([ @@ -84,8 +100,11 @@ const api = { console.warn(`MastoAPI websocket disconnected, restarting. CloseEvent code: ${code}`) dispatch('startFetchingTimeline', { timeline: 'friends' }) dispatch('startFetchingNotifications') + dispatch('startFetchingChats') dispatch('restartMastoUserSocket') } + commit('setMastoUserSocketStatus', WSConnectionStatus.CLOSED) + dispatch('clearOpenedChats') }) resolve() } catch (e) { @@ -99,12 +118,13 @@ const api = { return dispatch('startMastoUserSocket').then(() => { dispatch('stopFetchingTimeline', { timeline: 'friends' }) dispatch('stopFetchingNotifications') + dispatch('stopFetchingChats') }) }, stopMastoUserSocket ({ state, dispatch }) { dispatch('startFetchingTimeline', { timeline: 'friends' }) dispatch('startFetchingNotifications') - console.log(state.mastoUserSocket) + dispatch('startFetchingChats') state.mastoUserSocket.close() }, diff --git a/src/modules/chats.js b/src/modules/chats.js new file mode 100644 index 00000000..228d6256 --- /dev/null +++ b/src/modules/chats.js @@ -0,0 +1,225 @@ +import Vue from 'vue' +import { find, omitBy, orderBy, sumBy } from 'lodash' +import chatService from '../services/chat_service/chat_service.js' +import { parseChat, parseChatMessage } from '../services/entity_normalizer/entity_normalizer.service.js' + +const emptyChatList = () => ({ + data: [], + idStore: {} +}) + +const defaultState = { + chatList: emptyChatList(), + chatListFetcher: null, + openedChats: {}, + openedChatMessageServices: {}, + fetcher: undefined, + currentChatId: null +} + +const getChatById = (state, id) => { + return find(state.chatList.data, { id }) +} + +const sortedChatList = (state) => { + return orderBy(state.chatList.data, ['updated_at'], ['desc']) +} + +const unreadChatCount = (state) => { + return sumBy(state.chatList.data, 'unread') +} + +const chats = { + state: { ...defaultState }, + getters: { + currentChat: state => state.openedChats[state.currentChatId], + currentChatMessageService: state => state.openedChatMessageServices[state.currentChatId], + findOpenedChatByRecipientId: state => recipientId => find(state.openedChats, c => c.account.id === recipientId), + sortedChatList, + unreadChatCount + }, + actions: { + // Chat list + startFetchingChats ({ dispatch, commit }) { + const fetcher = () => { + dispatch('fetchChats', { latest: true }) + } + fetcher() + commit('setChatListFetcher', { + fetcher: () => setInterval(() => { fetcher() }, 5000) + }) + }, + stopFetchingChats ({ commit }) { + commit('setChatListFetcher', { fetcher: undefined }) + }, + fetchChats ({ dispatch, rootState, commit }, params = {}) { + return rootState.api.backendInteractor.chats() + .then(({ chats }) => { + dispatch('addNewChats', { chats }) + return chats + }) + }, + addNewChats ({ rootState, commit, dispatch, rootGetters }, { chats }) { + commit('addNewChats', { dispatch, chats, rootGetters }) + }, + updateChat ({ commit }, { chat }) { + commit('updateChat', { chat }) + }, + + // Opened Chats + startFetchingCurrentChat ({ commit, dispatch }, { fetcher }) { + dispatch('setCurrentChatFetcher', { fetcher }) + }, + setCurrentChatFetcher ({ rootState, commit }, { fetcher }) { + commit('setCurrentChatFetcher', { fetcher }) + }, + addOpenedChat ({ rootState, commit, dispatch }, { chat }) { + commit('addOpenedChat', { dispatch, chat: parseChat(chat) }) + dispatch('addNewUsers', [chat.account]) + }, + addChatMessages ({ commit }, value) { + commit('addChatMessages', { commit, ...value }) + }, + resetChatNewMessageCount ({ commit }, value) { + commit('resetChatNewMessageCount', value) + }, + clearCurrentChat ({ rootState, commit, dispatch }, value) { + commit('setCurrentChatId', { chatId: undefined }) + commit('setCurrentChatFetcher', { fetcher: undefined }) + }, + readChat ({ rootState, commit, dispatch }, { id, lastReadId }) { + dispatch('resetChatNewMessageCount') + commit('readChat', { id }) + rootState.api.backendInteractor.readChat({ id, lastReadId }) + }, + deleteChatMessage ({ rootState, commit }, value) { + rootState.api.backendInteractor.deleteChatMessage(value) + commit('deleteChatMessage', { commit, ...value }) + }, + resetChats ({ commit, dispatch }) { + dispatch('clearCurrentChat') + commit('resetChats', { commit }) + }, + clearOpenedChats ({ rootState, commit, dispatch, rootGetters }) { + commit('clearOpenedChats', { commit }) + } + }, + mutations: { + setChatListFetcher (state, { commit, fetcher }) { + const prevFetcher = state.chatListFetcher + if (prevFetcher) { + clearInterval(prevFetcher) + } + state.chatListFetcher = fetcher && fetcher() + }, + setCurrentChatFetcher (state, { fetcher }) { + const prevFetcher = state.fetcher + if (prevFetcher) { + clearInterval(prevFetcher) + } + state.fetcher = fetcher && fetcher() + }, + addOpenedChat (state, { _dispatch, chat }) { + state.currentChatId = chat.id + Vue.set(state.openedChats, chat.id, chat) + + if (!state.openedChatMessageServices[chat.id]) { + Vue.set(state.openedChatMessageServices, chat.id, chatService.empty(chat.id)) + } + }, + setCurrentChatId (state, { chatId }) { + state.currentChatId = chatId + }, + addNewChats (state, { _dispatch, chats, _rootGetters }) { + chats.forEach((updatedChat) => { + const chat = getChatById(state, updatedChat.id) + + if (chat) { + chat.lastMessage = updatedChat.lastMessage + chat.unread = updatedChat.unread + } else { + state.chatList.data.push(updatedChat) + Vue.set(state.chatList.idStore, updatedChat.id, updatedChat) + } + }) + }, + updateChat (state, { _dispatch, chat: updatedChat, _rootGetters }) { + const chat = getChatById(state, updatedChat.id) + if (chat) { + chat.lastMessage = updatedChat.lastMessage + chat.unread = updatedChat.unread + chat.updated_at = updatedChat.updated_at + } + if (!chat) { state.chatList.data.unshift(updatedChat) } + Vue.set(state.chatList.idStore, updatedChat.id, updatedChat) + }, + deleteChat (state, { _dispatch, id, _rootGetters }) { + state.chats.data = state.chats.data.filter(conversation => + conversation.last_status.id !== id + ) + state.chats.idStore = omitBy(state.chats.idStore, conversation => conversation.last_status.id === id) + }, + resetChats (state, { commit }) { + state.chatList = emptyChatList() + state.currentChatId = null + commit('setChatListFetcher', { fetcher: undefined }) + for (const chatId in state.openedChats) { + chatService.clear(state.openedChatMessageServices[chatId]) + Vue.delete(state.openedChats, chatId) + Vue.delete(state.openedChatMessageServices, chatId) + } + }, + setChatsLoading (state, { value }) { + state.chats.loading = value + }, + addChatMessages (state, { commit, chatId, messages }) { + const chatMessageService = state.openedChatMessageServices[chatId] + if (chatMessageService) { + chatService.add(chatMessageService, { messages: messages.map(parseChatMessage) }) + commit('refreshLastMessage', { chatId }) + } + }, + refreshLastMessage (state, { chatId }) { + const chatMessageService = state.openedChatMessageServices[chatId] + if (chatMessageService) { + const chat = getChatById(state, chatId) + if (chat) { + chat.lastMessage = chatMessageService.lastMessage + if (chatMessageService.lastMessage) { + chat.updated_at = chatMessageService.lastMessage.created_at + } + } + } + }, + deleteChatMessage (state, { commit, chatId, messageId }) { + const chatMessageService = state.openedChatMessageServices[chatId] + if (chatMessageService) { + chatService.deleteMessage(chatMessageService, messageId) + commit('refreshLastMessage', { chatId }) + } + }, + resetChatNewMessageCount (state, _value) { + const chatMessageService = state.openedChatMessageServices[state.currentChatId] + chatService.resetNewMessageCount(chatMessageService) + }, + // Used when a connection loss occurs + clearOpenedChats (state) { + const currentChatId = state.currentChatId + for (const chatId in state.openedChats) { + if (currentChatId !== chatId) { + chatService.clear(state.openedChatMessageServices[chatId]) + Vue.delete(state.openedChats, chatId) + Vue.delete(state.openedChatMessageServices, chatId) + } + } + }, + readChat (state, { id }) { + const chat = getChatById(state, id) + if (chat) { + chat.unread = 0 + } + } + } +} + +export default chats diff --git a/src/modules/config.js b/src/modules/config.js index 47b24d77..e0fe72df 100644 --- a/src/modules/config.js +++ b/src/modules/config.js @@ -46,7 +46,8 @@ export const defaultState = { repeats: true, moves: true, emojiReactions: false, - followRequest: true + followRequest: true, + chatMention: true }, webPushNotifications: false, muteWords: [], diff --git a/src/modules/instance.js b/src/modules/instance.js index ec5f4e54..3fe3bbf3 100644 --- a/src/modules/instance.js +++ b/src/modules/instance.js @@ -15,6 +15,8 @@ const defaultState = { // Stuff from static/config.json alwaysShowSubjectInput: true, + defaultAvatar: '/images/avi.png', + defaultBanner: '/images/banner.png', background: '/static/aurora_borealis.jpg', collapseMessageWithSubject: false, disableChat: false, @@ -53,6 +55,7 @@ const defaultState = { // Feature-set, apparently, not everything here is reported... chatAvailable: false, + pleromaChatMessagesAvailable: false, gopherAvailable: false, mediaProxyAvailable: false, suggestionsEnabled: false, diff --git a/src/modules/interface.js b/src/modules/interface.js index e31630fc..ec08ac0a 100644 --- a/src/modules/interface.js +++ b/src/modules/interface.js @@ -15,7 +15,8 @@ const defaultState = { ) }, mobileLayout: false, - globalNotices: [] + globalNotices: [], + layoutHeight: 0 } const interfaceMod = { @@ -65,6 +66,9 @@ const interfaceMod = { }, removeGlobalNotice (state, notice) { state.globalNotices = state.globalNotices.filter(n => n !== notice) + }, + setLayoutHeight (state, value) { + state.layoutHeight = value } }, actions: { @@ -110,6 +114,9 @@ const interfaceMod = { }, removeGlobalNotice ({ commit }, notice) { commit('removeGlobalNotice', notice) + }, + setLayoutHeight ({ commit }, value) { + commit('setLayoutHeight', value) } } } diff --git a/src/modules/statuses.js b/src/modules/statuses.js index 7fbf685c..64f5b587 100644 --- a/src/modules/statuses.js +++ b/src/modules/statuses.js @@ -478,7 +478,7 @@ export const mutations = { }, setDeleted (state, { status }) { const newStatus = state.allStatusesObject[status.id] - newStatus.deleted = true + if (newStatus) newStatus.deleted = true }, setManyDeleted (state, condition) { Object.values(state.allStatusesObject).forEach(status => { @@ -521,6 +521,9 @@ export const mutations = { dismissNotification (state, { id }) { state.notifications.data = state.notifications.data.filter(n => n.id !== id) }, + dismissNotifications (state, { finder }) { + state.notifications.data = state.notifications.data.filter(n => finder) + }, updateNotification (state, { id, updater }) { const notification = find(state.notifications.data, n => n.id === id) notification && updater(notification) diff --git a/src/modules/users.js b/src/modules/users.js index 7e136c61..16c1e566 100644 --- a/src/modules/users.js +++ b/src/modules/users.js @@ -498,6 +498,7 @@ const users = { store.dispatch('stopFetchingFollowRequests') store.commit('clearNotifications') store.commit('resetStatuses') + store.dispatch('resetChats') }) }, loginUser (store, accessToken) { @@ -537,6 +538,9 @@ const users = { // Start fetching notifications store.dispatch('startFetchingNotifications') + + // Start fetching chats + store.dispatch('startFetchingChats') } if (store.getters.mergedConfig.useStreamingApi) { @@ -544,6 +548,7 @@ const users = { console.error('Failed initializing MastoAPI Streaming socket', error) startPolling() }).then(() => { + store.dispatch('fetchChats', { latest: true }) setTimeout(() => store.dispatch('setNotificationsSilence', false), 10000) }) } else { diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js index ad543c6c..40ea5bd9 100644 --- a/src/services/api/api.service.js +++ b/src/services/api/api.service.js @@ -1,5 +1,5 @@ import { each, map, concat, last, get } from 'lodash' -import { parseStatus, parseUser, parseNotification, parseAttachment, parseLinkHeaderPagination } from '../entity_normalizer/entity_normalizer.service.js' +import { parseStatus, parseUser, parseNotification, parseAttachment, parseChat, parseLinkHeaderPagination } from '../entity_normalizer/entity_normalizer.service.js' import { RegistrationError, StatusCodeError } from '../errors/errors' /* eslint-env browser */ @@ -81,6 +81,11 @@ const MASTODON_KNOWN_DOMAIN_LIST_URL = '/api/v1/instance/peers' const PLEROMA_EMOJI_REACTIONS_URL = id => `/api/v1/pleroma/statuses/${id}/reactions` const PLEROMA_EMOJI_REACT_URL = (id, emoji) => `/api/v1/pleroma/statuses/${id}/reactions/${emoji}` const PLEROMA_EMOJI_UNREACT_URL = (id, emoji) => `/api/v1/pleroma/statuses/${id}/reactions/${emoji}` +const PLEROMA_CHATS_URL = `/api/v1/pleroma/chats` +const PLEROMA_CHAT_URL = id => `/api/v1/pleroma/chats/by-account-id/${id}` +const PLEROMA_CHAT_MESSAGES_URL = id => `/api/v1/pleroma/chats/${id}/messages` +const PLEROMA_CHAT_READ_URL = id => `/api/v1/pleroma/chats/${id}/read` +const PLEROMA_DELETE_CHAT_MESSAGE_URL = (chatId, messageId) => `/api/v1/pleroma/chats/${chatId}/messages/${messageId}` const oldfetch = window.fetch @@ -141,20 +146,11 @@ const updateNotificationSettings = ({ credentials, settings }) => { }).then((data) => data.json()) } -const updateAvatar = ({ credentials, avatar }) => { +const updateProfileImages = ({ credentials, avatar = null, banner = null, background = null }) => { const form = new FormData() - form.append('avatar', avatar) - return fetch(MASTODON_PROFILE_UPDATE_URL, { - headers: authHeaders(credentials), - method: 'PATCH', - body: form - }).then((data) => data.json()) - .then((data) => parseUser(data)) -} - -const updateBg = ({ credentials, background }) => { - const form = new FormData() - form.append('pleroma_background_image', background) + if (avatar !== null) form.append('avatar', avatar) + if (banner !== null) form.append('header', banner) + if (background !== null) form.append('pleroma_background_image', background) return fetch(MASTODON_PROFILE_UPDATE_URL, { headers: authHeaders(credentials), method: 'PATCH', @@ -164,17 +160,6 @@ const updateBg = ({ credentials, background }) => { .then((data) => parseUser(data)) } -const updateBanner = ({ credentials, banner }) => { - const form = new FormData() - form.append('header', banner) - return fetch(MASTODON_PROFILE_UPDATE_URL, { - headers: authHeaders(credentials), - method: 'PATCH', - body: form - }).then((data) => data.json()) - .then((data) => parseUser(data)) -} - const updateProfile = ({ credentials, params }) => { return promisedRequest({ url: MASTODON_PROFILE_UPDATE_URL, @@ -1087,6 +1072,10 @@ const MASTODON_STREAMING_EVENTS = new Set([ 'filters_changed' ]) +const PLEROMA_STREAMING_EVENTS = new Set([ + 'pleroma:chat_update' +]) + // A thin wrapper around WebSocket API that allows adding a pre-processor to it // Uses EventTarget and a CustomEvent to proxy events export const ProcessedWS = ({ @@ -1143,7 +1132,7 @@ export const handleMastoWS = (wsEvent) => { if (!data) return const parsedEvent = JSON.parse(data) const { event, payload } = parsedEvent - if (MASTODON_STREAMING_EVENTS.has(event)) { + if (MASTODON_STREAMING_EVENTS.has(event) || PLEROMA_STREAMING_EVENTS.has(event)) { // MastoBE and PleromaBE both send payload for delete as a PLAIN string if (event === 'delete') { return { event, id: payload } @@ -1153,6 +1142,8 @@ export const handleMastoWS = (wsEvent) => { return { event, status: parseStatus(data) } } else if (event === 'notification') { return { event, notification: parseNotification(data) } + } else if (event === 'pleroma:chat_update') { + return { event, chatUpdate: parseChat(data) } } } else { console.warn('Unknown event', wsEvent) @@ -1160,6 +1151,81 @@ export const handleMastoWS = (wsEvent) => { } } +export const WSConnectionStatus = Object.freeze({ + 'JOINED': 1, + 'CLOSED': 2, + 'ERROR': 3 +}) + +const chats = ({ credentials }) => { + return fetch(PLEROMA_CHATS_URL, { headers: authHeaders(credentials) }) + .then((data) => data.json()) + .then((data) => { + return { chats: data.map(parseChat).filter(c => c) } + }) +} + +const getOrCreateChat = ({ accountId, credentials }) => { + return promisedRequest({ + url: PLEROMA_CHAT_URL(accountId), + method: 'POST', + credentials + }) +} + +const chatMessages = ({ id, credentials, maxId, sinceId, limit = 20 }) => { + let url = PLEROMA_CHAT_MESSAGES_URL(id) + const args = [ + maxId && `max_id=${maxId}`, + sinceId && `since_id=${sinceId}`, + limit && `limit=${limit}` + ].filter(_ => _).join('&') + + url = url + (args ? '?' + args : '') + + return promisedRequest({ + url, + method: 'GET', + credentials + }) +} + +const sendChatMessage = ({ id, content, mediaId = null, credentials }) => { + const payload = { + 'content': content + } + + if (mediaId) { + payload['media_id'] = mediaId + } + + return promisedRequest({ + url: PLEROMA_CHAT_MESSAGES_URL(id), + method: 'POST', + payload: payload, + credentials + }) +} + +const readChat = ({ id, lastReadId, credentials }) => { + return promisedRequest({ + url: PLEROMA_CHAT_READ_URL(id), + method: 'POST', + payload: { + 'last_read_id': lastReadId + }, + credentials + }) +} + +const deleteChatMessage = ({ chatId, messageId, credentials }) => { + return promisedRequest({ + url: PLEROMA_DELETE_CHAT_MESSAGE_URL(chatId, messageId), + method: 'DELETE', + credentials + }) +} + const apiService = { verifyCredentials, fetchTimeline, @@ -1206,10 +1272,8 @@ const apiService = { deactivateUser, register, getCaptcha, - updateAvatar, - updateBg, + updateProfileImages, updateProfile, - updateBanner, importBlocks, importFollows, deleteAccount, @@ -1240,7 +1304,13 @@ const apiService = { fetchKnownDomains, fetchDomainMutes, muteDomain, - unmuteDomain + unmuteDomain, + chats, + getOrCreateChat, + chatMessages, + sendChatMessage, + readChat, + deleteChatMessage } export default apiService diff --git a/src/services/chat_service/chat_service.js b/src/services/chat_service/chat_service.js new file mode 100644 index 00000000..b60a889b --- /dev/null +++ b/src/services/chat_service/chat_service.js @@ -0,0 +1,151 @@ +import _ from 'lodash' + +const empty = (chatId) => { + return { + idIndex: {}, + messages: [], + newMessageCount: 0, + lastSeenTimestamp: 0, + chatId: chatId, + minId: undefined, + lastMessage: undefined + } +} + +const clear = (storage) => { + storage.idIndex = {} + storage.messages.splice(0, storage.messages.length) + storage.newMessageCount = 0 + storage.lastSeenTimestamp = 0 + storage.minId = undefined + storage.lastMessage = undefined +} + +const deleteMessage = (storage, messageId) => { + if (!storage) { return } + storage.messages = storage.messages.filter(m => m.id !== messageId) + delete storage.idIndex[messageId] + + if (storage.lastMessage && (storage.lastMessage.id === messageId)) { + storage.lastMessage = _.maxBy(storage.messages, 'id') + } + + if (storage.minId === messageId) { + const firstMessage = _.minBy(storage.messages, 'id') + storage.minId = firstMessage.id + } +} + +const add = (storage, { messages: newMessages }) => { + if (!storage) { return } + for (let i = 0; i < newMessages.length; i++) { + const message = newMessages[i] + + // sanity check + if (message.chat_id !== storage.chatId) { return } + + if (!storage.minId || message.id < storage.minId) { + storage.minId = message.id + } + + if (!storage.lastMessage || message.id > storage.lastMessage.id) { + storage.lastMessage = message + } + + if (!storage.idIndex[message.id]) { + if (storage.lastSeenTimestamp < message.created_at) { + storage.newMessageCount++ + } + storage.messages.push(message) + storage.idIndex[message.id] = message + } + } +} + +const resetNewMessageCount = (storage) => { + if (!storage) { return } + storage.newMessageCount = 0 + storage.lastSeenTimestamp = new Date() +} + +// Inserts date separators and marks the head and tail if it's the chain of messages made by the same user +const getView = (storage) => { + if (!storage) { return [] } + + const result = [] + const messages = _.sortBy(storage.messages, ['id', 'desc']) + const firstMessage = messages[0] + let previousMessage = messages[messages.length - 1] + let currentMessageChainId + + if (firstMessage) { + const date = new Date(firstMessage.created_at) + date.setHours(0, 0, 0, 0) + result.push({ + type: 'date', + date, + id: date.getTime().toString() + }) + } + + let afterDate = false + + for (let i = 0; i < messages.length; i++) { + const message = messages[i] + const nextMessage = messages[i + 1] + + const date = new Date(message.created_at) + date.setHours(0, 0, 0, 0) + + // insert date separator and start a new message chain + if (previousMessage && previousMessage.date < date) { + result.push({ + type: 'date', + date, + id: date.getTime().toString() + }) + + previousMessage['isTail'] = true + currentMessageChainId = undefined + afterDate = true + } + + const object = { + type: 'message', + data: message, + date, + id: message.id, + messageChainId: currentMessageChainId + } + + // end a message chian + if ((nextMessage && nextMessage.account_id) !== message.account_id) { + object['isTail'] = true + currentMessageChainId = undefined + } + + // start a new message chain + if ((previousMessage && previousMessage.data && previousMessage.data.account_id) !== message.account_id || afterDate) { + currentMessageChainId = _.uniqueId() + object['isHead'] = true + object['messageChainId'] = currentMessageChainId + } + + result.push(object) + previousMessage = object + afterDate = false + } + + return result +} + +const ChatService = { + add, + empty, + getView, + deleteMessage, + resetNewMessageCount, + clear +} + +export default ChatService diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js index ec83c02a..7ea8a16c 100644 --- a/src/services/entity_normalizer/entity_normalizer.service.js +++ b/src/services/entity_normalizer/entity_normalizer.service.js @@ -183,6 +183,7 @@ export const parseUser = (data) => { output.deactivated = data.pleroma.deactivated output.notification_settings = data.pleroma.notification_settings + output.unread_chat_count = data.pleroma.unread_chat_count } output.tags = output.tags || [] @@ -372,7 +373,7 @@ export const parseNotification = (data) => { ? parseStatus(data.notice.favorited_status) : parsedNotice output.action = parsedNotice - output.from_profile = parseUser(data.from_profile) + output.from_profile = output.type === 'pleroma:chat_mention' ? parseUser(data.account) : parseUser(data.from_profile) } output.created_at = new Date(data.created_at) @@ -398,3 +399,34 @@ export const parseLinkHeaderPagination = (linkHeader, opts = {}) => { minId: flakeId ? minId : parseInt(minId, 10) } } + +export const parseChat = (chat) => { + const output = {} + output.id = chat.id + output.account = parseUser(chat.account) + output.unread = chat.unread + output.lastMessage = parseChatMessage(chat.last_message) + output.updated_at = new Date(chat.updated_at) + return output +} + +export const parseChatMessage = (message) => { + if (!message) { return } + if (message.isNormalized) { return message } + const output = message + output.id = message.id + output.created_at = new Date(message.created_at) + output.chat_id = message.chat_id + if (message.content) { + output.content = addEmojis(message.content, message.emojis) + } else { + output.content = '' + } + if (message.attachment) { + output.attachments = [parseAttachment(message.attachment)] + } else { + output.attachments = [] + } + output.isNormalized = true + return output +} diff --git a/src/services/style_setter/style_setter.js b/src/services/style_setter/style_setter.js index fbdcf562..07425abd 100644 --- a/src/services/style_setter/style_setter.js +++ b/src/services/style_setter/style_setter.js @@ -106,7 +106,8 @@ export const generateRadii = (input) => { avatar: 5, avatarAlt: 50, tooltip: 2, - attachment: 5 + attachment: 5, + chatMessage: inputRadii.panel }) return { diff --git a/src/services/theme_data/pleromafe.js b/src/services/theme_data/pleromafe.js index 6b25cd6f..b58ca9be 100644 --- a/src/services/theme_data/pleromafe.js +++ b/src/services/theme_data/pleromafe.js @@ -23,7 +23,9 @@ export const LAYERS = { inputTopBar: 'topBar', alert: 'bg', alertPanel: 'panel', - poll: 'bg' + poll: 'bg', + chatBg: 'underlay', + chatMessage: 'chatBg' } /* By default opacity slots have 1 as default opacity @@ -667,5 +669,54 @@ export const SLOT_INHERITANCE = { layer: 'badge', variant: 'badgeNotification', textColor: 'bw' + }, + + chatBg: { + depends: ['bg'] + }, + + chatMessage: { + depends: ['chatBg'] + }, + + chatMessageIncomingBg: { + depends: ['chatMessage'], + layer: 'chatMessage' + }, + + chatMessageIncomingText: { + depends: ['text'], + layer: 'text' + }, + + chatMessageIncomingLink: { + depends: ['link'], + layer: 'link' + }, + + chatMessageIncomingBorder: { + depends: ['border'], + opacity: 'border', + color: (mod, border) => brightness(2 * mod, border).rgb + }, + + chatMessageOutgoingBg: { + depends: ['chatMessage'], + color: (mod, chatMessage) => brightness(5 * mod, chatMessage).rgb + }, + + chatMessageOutgoingText: { + depends: ['text'], + layer: 'text' + }, + + chatMessageOutgoingLink: { + depends: ['link'], + layer: 'link' + }, + + chatMessageOutgoingBorder: { + depends: ['chatMessage'], + opacity: 'chatMessage' } } diff --git a/src/services/window_utils/window_utils.js b/src/services/window_utils/window_utils.js index faff6cb9..909088db 100644 --- a/src/services/window_utils/window_utils.js +++ b/src/services/window_utils/window_utils.js @@ -3,3 +3,8 @@ export const windowWidth = () => window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth + +export const windowHeight = () => + window.innerHeight || + document.documentElement.clientHeight || + document.body.clientHeight diff --git a/static/fontello.json b/static/fontello.json index 5ef8544e..706800cd 100644 --- a/static/fontello.json +++ b/static/fontello.json @@ -399,6 +399,12 @@ "css": "doc", "code": 59433, "src": "fontawesome" + }, + { + "uid": "98d9c83c1ee7c2c25af784b518c522c5", + "css": "block", + "code": 59434, + "src": "fontawesome" } ] }
\ No newline at end of file diff --git a/static/terms-of-service.html b/static/terms-of-service.html index b2c66815..3b6bbb36 100644 --- a/static/terms-of-service.html +++ b/static/terms-of-service.html @@ -2,7 +2,7 @@ <p>This is the default placeholder ToS. You should copy it over to your static folder and edit it to fit the needs of your instance.</p> -<p>To do so, place a file at <code>"/instance/static/terms-of-service.html"</code> in your +<p>To do so, place a file at <code>"/instance/static/static/terms-of-service.html"</code> in your Pleroma install containing the real ToS for your instance.</p> <p>See the <a href='https://docs.pleroma.social/backend/configuration/static_dir/'>Pleroma documentation</a> for more information.</p> <br> diff --git a/test/unit/specs/boot/routes.spec.js b/test/unit/specs/boot/routes.spec.js index a415aeaf..3673256f 100644 --- a/test/unit/specs/boot/routes.spec.js +++ b/test/unit/specs/boot/routes.spec.js @@ -1,14 +1,22 @@ +import Vuex from 'vuex' import routes from 'src/boot/routes' import { createLocalVue } from '@vue/test-utils' import VueRouter from 'vue-router' const localVue = createLocalVue() +localVue.use(Vuex) localVue.use(VueRouter) +const store = new Vuex.Store({ + state: { + instance: {} + } +}) + describe('routes', () => { const router = new VueRouter({ mode: 'abstract', - routes: routes({}) + routes: routes(store) }) it('root path', () => { diff --git a/test/unit/specs/services/chat_service/chat_service.spec.js b/test/unit/specs/services/chat_service/chat_service.spec.js new file mode 100644 index 00000000..4e8e566b --- /dev/null +++ b/test/unit/specs/services/chat_service/chat_service.spec.js @@ -0,0 +1,89 @@ +import chatService from '../../../../../src/services/chat_service/chat_service.js' + +const message1 = { + id: '9wLkdcmQXD21Oy8lEX', + created_at: (new Date('2020-06-22T18:45:53.000Z')) +} + +const message2 = { + id: '9wLkdp6ihaOVdNj8Wu', + account_id: '9vmRb29zLQReckr5ay', + created_at: (new Date('2020-06-22T18:45:56.000Z')) +} + +const message3 = { + id: '9wLke9zL4Dy4OZR2RM', + account_id: '9vmRb29zLQReckr5ay', + created_at: (new Date('2020-07-22T18:45:59.000Z')) +} + +// TODO: only +describe.only('chatService', () => { + describe('.add', () => { + it("Doesn't add duplicates", () => { + const chat = chatService.empty() + chatService.add(chat, { messages: [ message1 ] }) + chatService.add(chat, { messages: [ message1 ] }) + expect(chat.messages.length).to.eql(1) + + chatService.add(chat, { messages: [ message2 ] }) + expect(chat.messages.length).to.eql(2) + }) + + it('Updates minId and lastMessage and newMessageCount', () => { + const chat = chatService.empty() + + chatService.add(chat, { messages: [ message1 ] }) + expect(chat.lastMessage.id).to.eql(message1.id) + expect(chat.minId).to.eql(message1.id) + expect(chat.newMessageCount).to.eql(1) + + chatService.add(chat, { messages: [ message2 ] }) + expect(chat.lastMessage.id).to.eql(message2.id) + expect(chat.minId).to.eql(message1.id) + expect(chat.newMessageCount).to.eql(2) + + chatService.resetNewMessageCount(chat) + expect(chat.newMessageCount).to.eql(0) + + const createdAt = new Date() + createdAt.setSeconds(createdAt.getSeconds() + 10) + chatService.add(chat, { messages: [ { message3, created_at: createdAt } ] }) + expect(chat.newMessageCount).to.eql(1) + }) + }) + + describe('.delete', () => { + it('Updates minId and lastMessage', () => { + const chat = chatService.empty() + + chatService.add(chat, { messages: [ message1 ] }) + chatService.add(chat, { messages: [ message2 ] }) + chatService.add(chat, { messages: [ message3 ] }) + + expect(chat.lastMessage.id).to.eql(message3.id) + expect(chat.minId).to.eql(message1.id) + + chatService.deleteMessage(chat, message3.id) + expect(chat.lastMessage.id).to.eql(message2.id) + expect(chat.minId).to.eql(message1.id) + + chatService.deleteMessage(chat, message1.id) + expect(chat.lastMessage.id).to.eql(message2.id) + expect(chat.minId).to.eql(message2.id) + }) + }) + + describe('.getView', () => { + it('Inserts date separators', () => { + const chat = chatService.empty() + + chatService.add(chat, { messages: [ message1 ] }) + chatService.add(chat, { messages: [ message2 ] }) + chatService.add(chat, { messages: [ message3 ] }) + + const view = chatService.getView(chat) + expect(view.map(i => i.type)).to.eql(['date', 'message', 'message', 'date', 'message']) + }) + }) +}) |
