diff options
Diffstat (limited to 'src/components')
97 files changed, 3818 insertions, 1101 deletions
diff --git a/src/components/account_actions/account_actions.js b/src/components/account_actions/account_actions.js index 0826c275..6d345bc7 100644 --- a/src/components/account_actions/account_actions.js +++ b/src/components/account_actions/account_actions.js @@ -1,3 +1,4 @@ +import { mapState } from 'vuex' import ProgressButton from '../progress_button/progress_button.vue' import Popover from '../popover/popover.vue' @@ -27,7 +28,18 @@ const AccountActions = { }, reportUser () { this.$store.dispatch('openUserReportingModal', this.user.id) + }, + openChat () { + this.$router.push({ + name: 'chat', + params: { recipient_id: this.user.id } + }) } + }, + computed: { + ...mapState({ + pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable + }) } } diff --git a/src/components/account_actions/account_actions.vue b/src/components/account_actions/account_actions.vue index 029e7096..987e94b7 100644 --- a/src/components/account_actions/account_actions.vue +++ b/src/components/account_actions/account_actions.vue @@ -50,6 +50,13 @@ > {{ $t('user_card.report') }} </button> + <button + v-if="pleromaChatMessagesAvailable" + class="btn btn-default btn-block dropdown-item" + @click="openChat" + > + {{ $t('user_card.message') }} + </button> </div> </div> <div diff --git a/src/components/attachment/attachment.js b/src/components/attachment/attachment.js index b832e10f..cb31020d 100644 --- a/src/components/attachment/attachment.js +++ b/src/components/attachment/attachment.js @@ -8,7 +8,6 @@ const Attachment = { props: [ 'attachment', 'nsfw', - 'statusId', 'size', 'allowPlay', 'setMedia', @@ -30,9 +29,21 @@ const Attachment = { VideoAttachment }, computed: { - usePlaceHolder () { + usePlaceholder () { return this.size === 'hide' || this.type === 'unknown' }, + placeholderName () { + if (this.attachment.description === '' || !this.attachment.description) { + return this.type.toUpperCase() + } + return this.attachment.description + }, + placeholderIconClass () { + if (this.type === 'image') return 'icon-picture' + if (this.type === 'video') return 'icon-video' + if (this.type === 'audio') return 'icon-music' + return 'icon-doc' + }, referrerpolicy () { return this.$store.state.instance.mediaProxyAvailable ? '' : 'no-referrer' }, @@ -49,7 +60,15 @@ const Attachment = { return this.size === 'small' }, fullwidth () { - return this.type === 'html' || this.type === 'audio' + if (this.size === 'hide') return false + return this.type === 'html' || this.type === 'audio' || this.type === 'unknown' + }, + useModal () { + const modalTypes = this.size === 'hide' ? ['image', 'video', 'audio'] + : this.mergedConfig.playVideosInModal + ? ['image', 'video'] + : ['image'] + return modalTypes.includes(this.type) }, ...mapGetters(['mergedConfig']) }, @@ -60,12 +79,7 @@ const Attachment = { } }, openModal (event) { - const modalTypes = this.mergedConfig.playVideosInModal - ? ['image', 'video'] - : ['image'] - if (fileTypeService.fileMatchesSomeType(modalTypes, this.attachment) || - this.usePlaceHolder - ) { + if (this.useModal) { event.stopPropagation() event.preventDefault() this.setMedia() diff --git a/src/components/attachment/attachment.vue b/src/components/attachment/attachment.vue index a7e217c1..63e0ceba 100644 --- a/src/components/attachment/attachment.vue +++ b/src/components/attachment/attachment.vue @@ -1,6 +1,7 @@ <template> <div - v-if="usePlaceHolder" + v-if="usePlaceholder" + :class="{ 'fullwidth': fullwidth }" @click="openModal" > <a @@ -8,8 +9,11 @@ class="placeholder" target="_blank" :href="attachment.url" + :alt="attachment.description" + :title="attachment.description" > - [{{ nsfw ? "NSFW/" : "" }}{{ type.toUpperCase() }}] + <span :class="placeholderIconClass" /> + <b>{{ nsfw ? "NSFW / " : "" }}</b>{{ placeholderName }} </a> </div> <div @@ -22,6 +26,8 @@ v-if="hidden" class="image-attachment" :href="attachment.url" + :alt="attachment.description" + :title="attachment.description" @click.prevent="toggleHidden" > <img @@ -51,14 +57,15 @@ :class="{'hidden': hidden && preloadImage }" :href="attachment.url" target="_blank" - :title="attachment.description" @click="openModal" > <StillImage + class="image" :referrerpolicy="referrerpolicy" :mimetype="attachment.mimetype" :src="attachment.large_thumb_url || attachment.url" :image-load-handler="onImageLoad" + :alt="attachment.description" /> </a> @@ -83,6 +90,8 @@ <audio v-if="type === 'audio'" :src="attachment.url" + :alt="attachment.description" + :title="attachment.description" controls /> @@ -116,22 +125,19 @@ display: flex; flex-wrap: wrap; - .attachment.media-upload-container { - flex: 0 0 auto; - max-height: 200px; + .non-gallery { max-width: 100%; - display: flex; - align-items: center; - video { - max-width: 100%; - } } .placeholder { - margin-right: 8px; - margin-bottom: 4px; + display: inline-block; + padding: 0.3em 1em 0.3em 0; color: $fallback--link; color: var(--postLink, $fallback--link); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + max-width: 100%; } .nsfw-placeholder { @@ -276,8 +282,11 @@ } .image-attachment { - width: 100%; - height: 100%; + &, + & .image { + width: 100%; + height: 100%; + } &.hidden { display: none; diff --git a/src/components/bookmark_timeline/bookmark_timeline.js b/src/components/bookmark_timeline/bookmark_timeline.js new file mode 100644 index 00000000..64b69e5d --- /dev/null +++ b/src/components/bookmark_timeline/bookmark_timeline.js @@ -0,0 +1,17 @@ +import Timeline from '../timeline/timeline.vue' + +const Bookmarks = { + computed: { + timeline () { + return this.$store.state.statuses.timelines.bookmarks + } + }, + components: { + Timeline + }, + destroyed () { + this.$store.commit('clearTimeline', { timeline: 'bookmarks' }) + } +} + +export default Bookmarks diff --git a/src/components/bookmark_timeline/bookmark_timeline.vue b/src/components/bookmark_timeline/bookmark_timeline.vue new file mode 100644 index 00000000..8da6884b --- /dev/null +++ b/src/components/bookmark_timeline/bookmark_timeline.vue @@ -0,0 +1,9 @@ +<template> + <Timeline + :title="$t('nav.bookmarks')" + :timeline="timeline" + :timeline-name="'bookmarks'" + /> +</template> + +<script src="./bookmark_timeline.js"></script> 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..9e97b28e --- /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 { + border-radius: $fallback--avatarAltRadius; + border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius); + } + + .StatusContent { + 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..7d4ff60c --- /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..ca529b5a 100644 --- a/src/components/chat_panel/chat_panel.vue +++ b/src/components/chat_panel/chat_panel.vue @@ -10,7 +10,7 @@ @click.stop.prevent="togglePanel" > <div class="title"> - <span>{{ $t('chat.title') }}</span> + <span>{{ $t('shoutbox.title') }}</span> <i v-if="floating" class="icon-cancel" @@ -64,7 +64,7 @@ > <div class="title"> <i class="icon-comment-empty" /> - {{ $t('chat.title') }} + {{ $t('shoutbox.title') }} </div> </div> </div> @@ -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..b16ed39d --- /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 + } + } + + .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/conversation/conversation.vue b/src/components/conversation/conversation.vue index 2e48240a..997a4d10 100644 --- a/src/components/conversation/conversation.vue +++ b/src/components/conversation/conversation.vue @@ -1,7 +1,7 @@ <template> <div - class="timeline panel-default" - :class="[isExpanded ? 'panel' : 'panel-disabled']" + class="Conversation" + :class="{ '-expanded' : isExpanded, 'panel' : isExpanded }" > <div v-if="isExpanded" @@ -28,7 +28,7 @@ :replies="getReplies(status.id)" :in-profile="inProfile" :profile-user-id="profileUserId" - class="status-fadein panel-body" + class="conversation-status status-fadein panel-body" @goto="setHighlight" @toggleExpanded="toggleExpanded" /> @@ -40,14 +40,27 @@ <style lang="scss"> @import '../../_variables.scss'; -.timeline { - .panel-disabled { - .status-el { - border-left: none; - border-bottom-width: 1px; - border-bottom-style: solid; +.Conversation { + .conversation-status { + border-left: none; + border-bottom-width: 1px; + border-bottom-style: solid; + border-bottom-color: var(--border, $fallback--border); + border-radius: 0; + } + + &.-expanded { + .conversation-status { + border-color: $fallback--border; border-color: var(--border, $fallback--border); - border-radius: 0; + border-left: 4px solid $fallback--cRed; + border-left: 4px solid var(--cRed, $fallback--cRed); + } + + .conversation-status:last-child { + border-bottom: none; + border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius; + border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius); } } } 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/emoji_reactions/emoji_reactions.js b/src/components/emoji_reactions/emoji_reactions.js index ae7f53be..bb11b840 100644 --- a/src/components/emoji_reactions/emoji_reactions.js +++ b/src/components/emoji_reactions/emoji_reactions.js @@ -1,5 +1,5 @@ import UserAvatar from '../user_avatar/user_avatar.vue' -import Popover from '../popover/popover.vue' +import UserListPopover from '../user_list_popover/user_list_popover.vue' const EMOJI_REACTION_COUNT_CUTOFF = 12 @@ -7,7 +7,7 @@ const EmojiReactions = { name: 'EmojiReactions', components: { UserAvatar, - Popover + UserListPopover }, props: ['status'], data: () => ({ diff --git a/src/components/emoji_reactions/emoji_reactions.vue b/src/components/emoji_reactions/emoji_reactions.vue index bac4c605..2f14b5b2 100644 --- a/src/components/emoji_reactions/emoji_reactions.vue +++ b/src/components/emoji_reactions/emoji_reactions.vue @@ -1,44 +1,11 @@ <template> <div class="emoji-reactions"> - <Popover + <UserListPopover v-for="(reaction) in emojiReactions" :key="reaction.name" - trigger="hover" - placement="top" - :offset="{ y: 5 }" + :users="accountsForEmoji[reaction.name]" > - <div - slot="content" - class="reacted-users" - > - <div v-if="accountsForEmoji[reaction.name].length"> - <div - v-for="(account) in accountsForEmoji[reaction.name]" - :key="account.id" - class="reacted-user" - > - <UserAvatar - :user="account" - class="avatar-small" - :compact="true" - /> - <div class="reacted-user-names"> - <!-- eslint-disable vue/no-v-html --> - <span - class="reacted-user-name" - v-html="account.name_html" - /> - <!-- eslint-enable vue/no-v-html --> - <span class="reacted-user-screen-name">{{ account.screen_name }}</span> - </div> - </div> - </div> - <div v-else> - <i class="icon-spin4 animate-spin" /> - </div> - </div> <button - slot="trigger" class="emoji-reaction btn btn-default" :class="{ 'picked-reaction': reactedWith(reaction.name), 'not-clickable': !loggedIn }" @click="emojiOnClick(reaction.name, $event)" @@ -47,7 +14,7 @@ <span class="reaction-emoji">{{ reaction.name }}</span> <span>{{ reaction.count }}</span> </button> - </Popover> + </UserListPopover> <a v-if="tooManyReactions" class="emoji-reaction-expand faint" @@ -69,32 +36,6 @@ flex-wrap: wrap; } -.reacted-users { - padding: 0.5em; -} - -.reacted-user { - padding: 0.25em; - display: flex; - flex-direction: row; - - .reacted-user-names { - display: flex; - flex-direction: column; - margin-left: 0.5em; - min-width: 5em; - - img { - width: 1em; - height: 1em; - } - } - - .reacted-user-screen-name { - font-size: 9px; - } -} - .emoji-reaction { padding: 0 0.5em; margin-right: 0.5em; diff --git a/src/components/extra_buttons/extra_buttons.js b/src/components/extra_buttons/extra_buttons.js index e4b19d01..5e0c36bb 100644 --- a/src/components/extra_buttons/extra_buttons.js +++ b/src/components/extra_buttons/extra_buttons.js @@ -34,6 +34,16 @@ const ExtraButtons = { navigator.clipboard.writeText(this.statusLink) .then(() => this.$emit('onSuccess')) .catch(err => this.$emit('onError', err.error.error)) + }, + bookmarkStatus () { + this.$store.dispatch('bookmark', { id: this.status.id }) + .then(() => this.$emit('onSuccess')) + .catch(err => this.$emit('onError', err.error.error)) + }, + unbookmarkStatus () { + this.$store.dispatch('unbookmark', { id: this.status.id }) + .then(() => this.$emit('onSuccess')) + .catch(err => this.$emit('onError', err.error.error)) } }, computed: { diff --git a/src/components/extra_buttons/extra_buttons.vue b/src/components/extra_buttons/extra_buttons.vue index 68db6fd8..7a4e8642 100644 --- a/src/components/extra_buttons/extra_buttons.vue +++ b/src/components/extra_buttons/extra_buttons.vue @@ -41,6 +41,22 @@ <i class="icon-pin" /><span>{{ $t("status.unpin") }}</span> </button> <button + v-if="!status.bookmarked" + class="dropdown-item dropdown-item-icon" + @click.prevent="bookmarkStatus" + @click="close" + > + <i class="icon-bookmark-empty" /><span>{{ $t("status.bookmark") }}</span> + </button> + <button + v-if="status.bookmarked" + class="dropdown-item dropdown-item-icon" + @click.prevent="unbookmarkStatus" + @click="close" + > + <i class="icon-bookmark" /><span>{{ $t("status.unbookmark") }}</span> + </button> + <button v-if="canDelete" class="dropdown-item dropdown-item-icon" @click.prevent="deleteStatus" 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/gallery/gallery.vue b/src/components/gallery/gallery.vue index 1ffa7b3c..ca91c9c1 100644 --- a/src/components/gallery/gallery.vue +++ b/src/components/gallery/gallery.vue @@ -50,9 +50,7 @@ align-content: stretch; } - // FIXME: specificity problem with this and .attachments.attachment - // we shouldn't have the need for .image here - .attachment.image { + .gallery-row-inner .attachment { margin: 0 0.5em 0 0; flex-grow: 1; height: 100%; diff --git a/src/components/global_notice_list/global_notice_list.js b/src/components/global_notice_list/global_notice_list.js new file mode 100644 index 00000000..3af29c23 --- /dev/null +++ b/src/components/global_notice_list/global_notice_list.js @@ -0,0 +1,15 @@ + +const GlobalNoticeList = { + computed: { + notices () { + return this.$store.state.interface.globalNotices + } + }, + methods: { + closeNotice (notice) { + this.$store.dispatch('removeGlobalNotice', notice) + } + } +} + +export default GlobalNoticeList diff --git a/src/components/global_notice_list/global_notice_list.vue b/src/components/global_notice_list/global_notice_list.vue new file mode 100644 index 00000000..0e4285cc --- /dev/null +++ b/src/components/global_notice_list/global_notice_list.vue @@ -0,0 +1,77 @@ +<template> + <div class="global-notice-list"> + <div + v-for="(notice, index) in notices" + :key="index" + class="alert global-notice" + :class="{ ['global-' + notice.level]: true }" + > + <div class="notice-message"> + {{ $t(notice.messageKey, notice.messageArgs) }} + </div> + <i + class="button-icon icon-cancel" + @click="closeNotice(notice)" + /> + </div> + </div> +</template> + +<script src="./global_notice_list.js"></script> + +<style lang="scss"> +@import '../../_variables.scss'; + +.global-notice-list { + position: fixed; + top: 50px; + width: 100%; + pointer-events: none; + z-index: 1001; + display: flex; + flex-direction: column; + align-items: center; + + .global-notice { + pointer-events: auto; + text-align: center; + width: 40em; + max-width: calc(100% - 3em); + display: flex; + padding-left: 1.5em; + line-height: 2em; + .notice-message { + flex: 1 1 100%; + } + i { + flex: 0 0; + width: 1.5em; + cursor: pointer; + } + } + + .global-error { + background-color: var(--alertPopupError, $fallback--cRed); + color: var(--alertPopupErrorText, $fallback--text); + i { + color: var(--alertPopupErrorText, $fallback--text); + } + } + + .global-warning { + background-color: var(--alertPopupWarning, $fallback--cOrange); + color: var(--alertPopupWarningText, $fallback--text); + i { + color: var(--alertPopupWarningText, $fallback--text); + } + } + + .global-info { + background-color: var(--alertPopupNeutral, $fallback--fg); + color: var(--alertPopupNeutralText, $fallback--text); + i { + color: var(--alertPopupNeutralText, $fallback--text); + } + } +} +</style> diff --git a/src/components/media_modal/media_modal.vue b/src/components/media_modal/media_modal.vue index 80d2a8b9..46931667 100644 --- a/src/components/media_modal/media_modal.vue +++ b/src/components/media_modal/media_modal.vue @@ -8,6 +8,8 @@ v-if="type === 'image'" class="modal-image" :src="currentMedia.url" + :alt="currentMedia.description" + :title="currentMedia.description" @touchstart.stop="mediaTouchStart" @touchmove.stop="mediaTouchMove" @click="hide" @@ -18,6 +20,14 @@ :attachment="currentMedia" :controls="true" /> + <audio + v-if="type === 'audio'" + class="modal-image" + :src="currentMedia.url" + :alt="currentMedia.description" + :title="currentMedia.description" + controls + /> <button v-if="canNavigate" :title="$t('media_modal.previous')" 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..b2b5d264 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 () { @@ -64,7 +69,7 @@ const MobileNav = { this.$refs.notifications.markAsSeen() }, onScroll ({ target: { scrollTop, clientHeight, scrollHeight } }) { - if (this.$store.getters.mergedConfig.autoLoad && scrollTop + clientHeight >= scrollHeight) { + if (scrollTop + clientHeight >= scrollHeight) { this.$refs.notifications.fetchOlderNotifications() } } 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 8f7edb7f..623dfaec 100644 --- a/src/components/nav_panel/nav_panel.js +++ b/src/components/nav_panel/nav_panel.js @@ -1,4 +1,5 @@ -import { mapState } from 'vuex' +import { timelineNames } from '../timeline_menu/timeline_menu.js' +import { mapState, mapGetters } from 'vuex' const NavPanel = { created () { @@ -6,13 +7,25 @@ const NavPanel = { this.$store.dispatch('startFetchingFollowRequests') } }, - computed: mapState({ - currentUser: state => state.users.currentUser, - chat: state => state.chat.channel, - followRequestCount: state => state.api.followRequests.length, - privateMode: state => state.instance.private, - federating: state => state.instance.federating - }) + computed: { + onTimelineRoute () { + return !!timelineNames()[this.$route.name] + }, + timelinesRoute () { + if (this.$store.state.interface.lastTimeline) { + return this.$store.state.interface.lastTimeline + } + return this.currentUser ? 'friends' : 'public-timeline' + }, + ...mapState({ + currentUser: state => state.users.currentUser, + followRequestCount: state => state.api.followRequests.length, + privateMode: state => state.instance.private, + federating: state => state.instance.federating, + pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable + }), + ...mapGetters(['unreadChatCount']) + } } export default NavPanel diff --git a/src/components/nav_panel/nav_panel.vue b/src/components/nav_panel/nav_panel.vue index 8cd04dc7..f8459fd1 100644 --- a/src/components/nav_panel/nav_panel.vue +++ b/src/components/nav_panel/nav_panel.vue @@ -2,9 +2,12 @@ <div class="nav-panel"> <div class="panel panel-default"> <ul> - <li v-if="currentUser"> - <router-link :to="{ name: 'friends' }"> - <i class="button-icon icon-home-2" /> {{ $t("nav.timeline") }} + <li v-if="currentUser || !privateMode"> + <router-link + :to="{ name: timelinesRoute }" + :class="onTimelineRoute && 'router-link-active'" + > + <i class="button-icon icon-home-2" /> {{ $t("nav.timelines") }} </router-link> </li> <li v-if="currentUser"> @@ -12,9 +15,15 @@ <i class="button-icon icon-bell-alt" /> {{ $t("nav.interactions") }} </router-link> </li> - <li v-if="currentUser"> - <router-link :to="{ name: 'dms', params: { username: currentUser.screen_name } }"> - <i class="button-icon icon-mail-alt" /> {{ $t("nav.dms") }} + <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"> @@ -28,16 +37,6 @@ </span> </router-link> </li> - <li v-if="currentUser || !privateMode"> - <router-link :to="{ name: 'public-timeline' }"> - <i class="button-icon icon-users" /> {{ $t("nav.public_tl") }} - </router-link> - </li> - <li v-if="federating && (currentUser || !privateMode)"> - <router-link :to="{ name: 'public-external-timeline' }"> - <i class="button-icon icon-globe" /> {{ $t("nav.twkn") }} - </router-link> - </li> <li> <router-link :to="{ name: 'about' }"> <i class="button-icon icon-info-circled" /> {{ $t("nav.about") }} 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/notification/notification.scss b/src/components/notification/notification.scss new file mode 100644 index 00000000..d0e63d81 --- /dev/null +++ b/src/components/notification/notification.scss @@ -0,0 +1,52 @@ +// TODO Copypaste from Status, should unify it somehow +.Notification { + &.-muted { + padding: 0.25em 0.6em; + height: 1.2em; + line-height: 1.2em; + text-overflow: ellipsis; + overflow: hidden; + display: flex; + flex-wrap: nowrap; + + & .status-username, + & .mute-thread, + & .mute-words { + word-wrap: normal; + word-break: normal; + white-space: nowrap; + } + + & .status-username, + & .mute-words { + text-overflow: ellipsis; + overflow: hidden; + } + + .status-username { + font-weight: normal; + flex: 0 1 auto; + margin-right: 0.2em; + font-size: smaller; + } + + .mute-thread { + flex: 0 0 auto; + } + + .mute-words { + flex: 1 0 5em; + margin-left: 0.2em; + + &::before { + content: ' '; + } + } + + .unmute { + flex: 0 0 auto; + margin-left: auto; + display: block; + } + } +} diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue index 044ac871..7fac3840 100644 --- a/src/components/notification/notification.vue +++ b/src/components/notification/notification.vue @@ -7,7 +7,7 @@ <div v-else> <div v-if="needMute && !unmuted" - class="container muted" + class="Notification container -muted" > <small> <router-link :to="userProfileLink"> @@ -168,3 +168,4 @@ </template> <script src="./notification.js"></script> +<style src="./notification.scss" lang="scss"></style> diff --git a/src/components/notifications/notifications.js b/src/components/notifications/notifications.js index 26ffbab6..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 { @@ -27,6 +28,11 @@ const Notifications = { seenToDisplayCount: DEFAULT_SEEN_TO_DISPLAY_COUNT } }, + created () { + const store = this.$store + const credentials = store.state.users.currentUser.credentials + notificationsFetcher.fetchAndUpdate({ store, credentials }) + }, computed: { mainClass () { return this.minimalMode ? '' : 'panel panel-default' @@ -46,23 +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 }, - created () { - const { dispatch } = this.$store - - dispatch('fetchAndUpdateNotifications') - }, 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..c6b2a5b5 100644 --- a/src/components/notifications/notifications.scss +++ b/src/components/notifications/notifications.scss @@ -39,7 +39,7 @@ word-wrap: break-word; word-break: break-word; - &:hover .animated.avatar { + &:hover .animated.Avatar { canvas { display: none; } @@ -60,16 +60,8 @@ height: 32px; } - .status-body { - color: $fallback--faint; - color: var(--faint, $fallback--faint); - a { - color: var(--faintLink); - } - .status-content a { - color: var(--postFaintLink); - } - } + --link: var(--faintLink); + --text: var(--faint); } .follow-request-accept { @@ -106,7 +98,8 @@ } } - .status-el { + /* TODO cleanup this */ + .Status { flex: 1; } @@ -118,6 +111,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/password_reset/password_reset.js b/src/components/password_reset/password_reset.js index 62e74e30..5d21d720 100644 --- a/src/components/password_reset/password_reset.js +++ b/src/components/password_reset/password_reset.js @@ -47,11 +47,6 @@ const passwordReset = { if (status === 204) { this.success = true this.error = null - } else if (status === 404 || status === 400) { - this.error = this.$t('password_reset.not_found') - this.$nextTick(() => { - this.$refs.email.focus() - }) } else if (status === 429) { this.throttled = true this.error = this.$t('password_reset.too_many_requests') diff --git a/src/components/poll/poll.vue b/src/components/poll/poll.vue index adbb0555..1858f3e1 100644 --- a/src/components/poll/poll.vue +++ b/src/components/poll/poll.vue @@ -17,7 +17,7 @@ <span class="result-percentage"> {{ percentageForOption(option.votes_count) }}% </span> - <span v-html="option.title_html"></span> + <span v-html="option.title_html" /> </div> <div class="result-fill" @@ -96,6 +96,7 @@ align-items: center; padding: 0.1em 0.25em; z-index: 1; + word-break: break-word; } .result-percentage { width: 3.5em; diff --git a/src/components/poll/poll_form.js b/src/components/poll/poll_form.js index c0c1ccf7..df93f038 100644 --- a/src/components/poll/poll_form.js +++ b/src/components/poll/poll_form.js @@ -75,6 +75,7 @@ export default { deleteOption (index, event) { if (this.options.length > 2) { this.options.splice(index, 1) + this.updatePollToParent() } }, convertExpiryToUnit (unit, amount) { diff --git a/src/components/popover/popover.js b/src/components/popover/popover.js index a40a9195..695f73b9 100644 --- a/src/components/popover/popover.js +++ b/src/components/popover/popover.js @@ -18,7 +18,9 @@ const Popover = { // Takes a x/y object and tells how many pixels to offset from // anchor point on either axis offset: Object, - // Additional styles you may want for the popover container + // Replaces the classes you may want for the popover container. + // Use 'popover-default' in addition to get the default popover + // styles with your custom class. popoverClass: String }, data () { @@ -106,7 +108,7 @@ const Popover = { // single translate or translate3d resulted in blurry text. this.styles = { opacity: 1, - transform: `translateX(${Math.floor(translateX)}px) translateY(${Math.floor(translateY)}px)` + transform: `translateX(${Math.round(translateX)}px) translateY(${Math.round(translateY)}px)` } }, showPopover () { diff --git a/src/components/popover/popover.vue b/src/components/popover/popover.vue index a271cb1b..5c99c509 100644 --- a/src/components/popover/popover.vue +++ b/src/components/popover/popover.vue @@ -14,7 +14,7 @@ ref="content" :style="styles" class="popover" - :class="popoverClass" + :class="popoverClass || 'popover-default'" > <slot name="content" @@ -34,6 +34,9 @@ z-index: 8; position: absolute; min-width: 0; +} + +.popover-default { transition: opacity 0.3s; box-shadow: 1px 1px 4px rgba(0,0,0,.6); diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js index 9027566f..ad149506 100644 --- a/src/components/post_status_form/post_status_form.js +++ b/src/components/post_status_form/post_status_form.js @@ -3,11 +3,13 @@ import MediaUpload from '../media_upload/media_upload.vue' import ScopeSelector from '../scope_selector/scope_selector.vue' import EmojiInput from '../emoji_input/emoji_input.vue' import PollForm from '../poll/poll_form.vue' +import Attachment from '../attachment/attachment.vue' +import StatusContent from '../status_content/status_content.vue' import fileTypeService from '../../services/file_type/file_type.service.js' import { findOffset } from '../../services/offset_finder/offset_finder.service.js' -import { reject, map, uniqBy } from 'lodash' +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) => { @@ -25,27 +27,54 @@ const buildMentionsString = ({ user, attentions = [] }, currentUser) => { return mentions.length > 0 ? mentions.join(' ') + ' ' : '' } +// Converts a string with px to a number like '2px' -> 2 +const pxStringToNumber = (str) => { + return Number(str.substring(0, str.length - 2)) +} + const PostStatusForm = { props: [ 'replyTo', 'repliedUser', 'attentions', 'copyMessageScope', - 'subject' + 'subject', + 'disableSubject', + 'disableScopeSelector', + 'disableNotice', + 'disableLockWarning', + 'disablePolls', + 'disableSensitivityCheckbox', + 'disableSubmit', + 'disablePreview', + 'placeholder', + 'maxHeight', + 'postHandler', + 'preserveFocus', + 'autoFocus', + 'fileLimit', + 'submitOnEnter', + 'emojiPickerPlacement' ], components: { MediaUpload, EmojiInput, PollForm, ScopeSelector, - Checkbox + Checkbox, + Attachment, + StatusContent }, mounted () { + this.updateIdempotencyKey() 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() } }, @@ -68,7 +97,7 @@ const PostStatusForm = { return { dropFiles: [], - submitDisabled: false, + uploadingFiles: false, error: null, posting: false, highlighted: 0, @@ -78,13 +107,18 @@ const PostStatusForm = { nsfw: false, files: [], poll: {}, + mediaDescriptions: {}, visibility: scope, contentType }, caret: 0, pollFormVisible: false, showDropIcon: 'hide', - dropStopTimeout: null + dropStopTimeout: null, + preview: null, + previewLoading: false, + emojiInputShown: false, + idempotencyKey: '' } }, computed: { @@ -153,28 +187,81 @@ 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 && this.newStatus.poll && this.newStatus.poll.error }, - ...mapGetters(['mergedConfig']) + showPreview () { + return !this.disablePreview && (!!this.preview || this.previewLoading) + }, + emptyStatus () { + return this.newStatus.status.trim() === '' && this.newStatus.files.length === 0 + }, + uploadFileLimitReached () { + return this.newStatus.files.length >= this.fileLimit + }, + ...mapGetters(['mergedConfig']), + ...mapState({ + mobileLayout: state => state.interface.mobileLayout + }) + }, + watch: { + 'newStatus': { + deep: true, + handler () { + this.statusChanged() + } + } }, methods: { - postStatus (newStatus) { + statusChanged () { + this.autoPreview() + this.updateIdempotencyKey() + }, + clearStatus () { + const newStatus = this.newStatus + 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() + 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() + }, + 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.newStatus.status === '') { - if (this.newStatus.files.length === 0) { - this.error = 'Cannot post an empty status with no files' - return - } + if (this.emptyStatus) { + this.error = this.$t('post_status.empty_status_error') + return } const poll = this.pollFormVisible ? this.newStatus.poll : {} @@ -184,7 +271,16 @@ const PostStatusForm = { } this.posting = true - statusPoster.postStatus({ + + try { + await this.setAllMediaDescriptions() + } catch (e) { + this.error = this.$t('post_status.media_description_error') + this.posting = false + return + } + + const postingOptions = { status: newStatus.status, spoilerText: newStatus.spoilerText || null, visibility: newStatus.visibility, @@ -193,52 +289,98 @@ const PostStatusForm = { store: this.$store, inReplyToStatusId: this.replyTo, contentType: newStatus.contentType, - poll - }).then((data) => { + poll, + idempotencyKey: this.idempotencyKey + } + + 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: {} - } - 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 + this.clearStatus() + this.$emit('posted', data) } else { this.error = data.error } this.posting = false }) }, + previewStatus () { + if (this.emptyStatus && this.newStatus.spoilerText.trim() === '') { + this.preview = { error: this.$t('post_status.preview_empty') } + this.previewLoading = false + return + } + const newStatus = this.newStatus + this.previewLoading = true + statusPoster.postStatus({ + status: newStatus.status, + spoilerText: newStatus.spoilerText || null, + visibility: newStatus.visibility, + sensitive: newStatus.nsfw, + media: [], + store: this.$store, + inReplyToStatusId: this.replyTo, + contentType: newStatus.contentType, + poll: {}, + preview: true + }).then((data) => { + // Don't apply preview if not loading, because it means + // user has closed the preview manually. + if (!this.previewLoading) return + if (!data.error) { + this.preview = data + } else { + this.preview = { error: data.error } + } + }).catch((error) => { + this.preview = { error } + }).finally(() => { + this.previewLoading = false + }) + }, + debouncePreviewStatus: debounce(function () { this.previewStatus() }, 500), + autoPreview () { + if (!this.preview) return + this.previewLoading = true + this.debouncePreviewStatus() + }, + closePreview () { + this.preview = null + this.previewLoading = false + }, + togglePreview () { + if (this.showPreview) { + this.closePreview() + } else { + this.previewStatus() + } + }, 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) }, paste (e) { + this.autoPreview() this.resize(e) if (e.clipboardData.files.length > 0) { // prevent pasting of file as text @@ -266,7 +408,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' @@ -284,6 +426,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 } @@ -295,7 +438,7 @@ const PostStatusForm = { * scroll is different for `Window` and `Element`s */ const bottomBottomPaddingStr = window.getComputedStyle(bottomRef)['padding-bottom'] - const bottomBottomPadding = Number(bottomBottomPaddingStr.substring(0, bottomBottomPaddingStr.length - 2)) + const bottomBottomPadding = pxStringToNumber(bottomBottomPaddingStr) const scrollerRef = this.$el.closest('.sidebar-scroller') || this.$el.closest('.post-form-modal-view') || @@ -304,10 +447,12 @@ const PostStatusForm = { // Getting info about padding we have to account for, removing 'px' part const topPaddingStr = window.getComputedStyle(target)['padding-top'] const bottomPaddingStr = window.getComputedStyle(target)['padding-bottom'] - const topPadding = Number(topPaddingStr.substring(0, topPaddingStr.length - 2)) - const bottomPadding = Number(bottomPaddingStr.substring(0, bottomPaddingStr.length - 2)) + const topPadding = pxStringToNumber(topPaddingStr) + const bottomPadding = pxStringToNumber(bottomPaddingStr) const vertPadding = topPadding + bottomPadding + const oldHeight = pxStringToNumber(target.style.height) + /* Explanation: * * https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight @@ -336,8 +481,15 @@ const PostStatusForm = { // BEGIN content size update target.style.height = 'auto' - const newHeight = target.scrollHeight - vertPadding + const heightWithoutPadding = Math.floor(target.scrollHeight - vertPadding) + let newHeight = this.maxHeight ? Math.min(heightWithoutPadding, this.maxHeight) : heightWithoutPadding + // This is a bit of a hack to combat target.scrollHeight being different on every other input + // on some browsers for whatever reason. Don't change the height if difference is 1px or less. + if (Math.abs(newHeight - oldHeight) <= 1) { + newHeight = oldHeight + } 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 @@ -388,6 +540,24 @@ const PostStatusForm = { }, dismissScopeNotice () { this.$store.dispatch('setOption', { name: 'hideScopeNotice', value: true }) + }, + setMediaDescription (id) { + const description = this.newStatus.mediaDescriptions[id] + if (!description || description.trim() === '') return + return statusPoster.setMediaDescription({ store: this.$store, id, description }) + }, + setAllMediaDescriptions () { + const ids = this.newStatus.files.map(file => file.id) + return Promise.all(ids.map(id => this.setMediaDescription(id))) + }, + handleEmojiInputShow (value) { + this.emojiInputShown = value + }, + updateIdempotencyKey () { + this.idempotencyKey = Date.now().toString() + }, + openProfileTab () { + this.$store.dispatch('openSettingsModalTab', 'profile') } } } diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue index e3d8d087..d67d9ae9 100644 --- a/src/components/post_status_form/post_status_form.vue +++ b/src/components/post_status_form/post_status_form.vue @@ -5,26 +5,30 @@ > <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" > - <router-link :to="{ name: 'user-settings' }"> + <a + href="#" + @click="openProfileTab" + > {{ $t('post_status.account_not_locked_warning_link') }} - </router-link> + </a> </i18n> <p v-if="!hideScopeNotice && newStatus.visibility === 'public'" @@ -69,15 +73,52 @@ <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 + v-if="!disablePreview" + class="preview-heading faint" + > + <a + class="preview-toggle faint" + @click.stop.prevent="togglePreview" + > + {{ $t('post_status.preview') }} + <i :class="showPreview ? 'icon-left-open' : 'icon-right-open'" /> + </a> + <i + v-show="previewLoading" + class="icon-spin3 animate-spin" + /> + </div> + <div + v-if="showPreview" + class="preview-container" + > + <div + v-if="!preview" + class="preview-status" + > + {{ $t('general.loading') }} + </div> + <div + v-else-if="preview.error" + class="preview-status preview-error" + > + {{ preview.error }} + </div> + <StatusContent + v-else + :status="preview" + class="preview-status" + /> + </div> <EmojiInput - v-if="newStatus.spoilerText || alwaysShowSubject" + v-if="!disableSubject && (newStatus.spoilerText || alwaysShowSubject)" v-model="newStatus.spoilerText" enable-emoji-picker :suggest="emojiSuggestor" class="form-control" > <input - v-model="newStatus.spoilerText" type="text" :placeholder="$t('post_status.content_warning')" @@ -89,23 +130,29 @@ 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" + cols="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" @@ -118,7 +165,10 @@ {{ charactersLeft }} </p> </EmojiInput> - <div class="visibility-tray"> + <div + v-if="!disableScopeSelector" + class="visibility-tray" + > <scope-selector :show-all="showAllScopes" :user-default="userDefaultScope" @@ -176,10 +226,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" @@ -216,11 +267,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> @@ -245,31 +298,22 @@ class="fa button-icon icon-cancel" @click="removeMediaFile(file)" /> - <div class="media-upload-container attachment"> - <img - v-if="type(file) === 'image'" - class="thumbnail media-upload" - :src="file.url" - > - <video - v-if="type(file) === 'video'" - :src="file.url" - controls - /> - <audio - v-if="type(file) === 'audio'" - :src="file.url" - controls - /> - <a - v-if="type(file) === 'unknown'" - :href="file.url" - >{{ file.url }}</a> - </div> + <attachment + :attachment="file" + :set-media="() => $store.dispatch('setMedia', newStatus.files)" + size="small" + allow-play="false" + /> + <input + v-model="newStatus.mediaDescriptions[file.id]" + type="text" + :placeholder="$t('post_status.media_description')" + @keydown.enter.prevent="" + > </div> </div> <div - v-if="newStatus.files.length > 0" + v-if="newStatus.files.length > 0 && !disableSensitivityCheckbox" class="upload_settings" > <Checkbox v-model="newStatus.nsfw"> @@ -303,14 +347,8 @@ } .post-status-form { - .visibility-tray { - display: flex; - justify-content: space-between; - padding-top: 5px; - } -} + position: relative; -.post-status-form { .form-bottom { display: flex; justify-content: space-between; @@ -336,6 +374,51 @@ max-width: 10em; } + .preview-heading { + padding-left: 0.5em; + display: flex; + width: 100%; + + .icon-spin3 { + margin-left: auto; + } + } + + .preview-toggle { + display: flex; + cursor: pointer; + user-select: none; + + &:hover { + text-decoration: underline; + } + i { + margin-left: 0.2em; + font-size: 0.8em; + transform: rotate(90deg); + } + } + + .preview-container { + margin-bottom: 1em; + } + + .preview-error { + font-style: italic; + color: $fallback--faint; + color: var(--faint, $fallback--faint); + } + + .preview-status { + border: 1px solid $fallback--border; + border: 1px solid var(--border, $fallback--border); + border-radius: $fallback--tooltipRadius; + border-radius: var(--tooltipRadius, $fallback--tooltipRadius); + padding: 0.5em; + margin: 0; + line-height: 1.4em; + } + .text-format { .only-format { color: $fallback--faint; @@ -343,6 +426,12 @@ } } + .visibility-tray { + display: flex; + justify-content: space-between; + padding-top: 5px; + } + .media-upload-icon, .poll-icon, .emoji-icon { font-size: 26px; flex: 1; @@ -354,6 +443,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 @@ -381,11 +483,9 @@ } .media-upload-wrapper { - flex: 0 0 auto; - max-width: 100%; - min-width: 50px; margin-right: .2em; margin-bottom: .5em; + width: 18em; .icon-cancel { display: inline-block; @@ -399,6 +499,20 @@ border-bottom-left-radius: 0; border-bottom-right-radius: 0; } + + img, video { + object-fit: contain; + max-height: 10em; + } + + .video { + max-height: 10em; + } + + input { + flex: 1; + width: 100%; + } } .status-input-wrapper { @@ -408,28 +522,13 @@ flex-direction: column; } - .attachments { + .media-upload-wrapper .attachments { padding: 0 0.5em; .attachment { margin: 0; + padding: 0; position: relative; - flex: 0 0 auto; - border: 1px solid $fallback--border; - border: 1px solid var(--border, $fallback--border); - text-align: center; - - audio { - min-width: 300px; - flex: 1 0 auto; - } - - a { - display: block; - text-align: left; - line-height: 1.2; - padding: .5em; - } } i { @@ -482,6 +581,10 @@ padding-bottom: 1.75em; min-height: 1px; box-sizing: content-box; + + &.scrollable-form { + overflow-y: auto; + } } .main-input { @@ -544,4 +647,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/react_button/react_button.js b/src/components/react_button/react_button.js index f0931446..abcf0455 100644 --- a/src/components/react_button/react_button.js +++ b/src/components/react_button/react_button.js @@ -28,7 +28,10 @@ const ReactButton = { }, emojis () { if (this.filterWord !== '') { - return this.$store.state.instance.emoji.filter(emoji => emoji.displayText.includes(this.filterWord)) + const filterWordLowercase = this.filterWord.toLowerCase() + return this.$store.state.instance.emoji.filter(emoji => + emoji.displayText.toLowerCase().includes(filterWordLowercase) + ) } return this.$store.state.instance.emoji || [] }, diff --git a/src/components/settings_modal/settings_modal.scss b/src/components/settings_modal/settings_modal.scss index 833ff89a..90446b36 100644 --- a/src/components/settings_modal/settings_modal.scss +++ b/src/components/settings_modal/settings_modal.scss @@ -13,6 +13,13 @@ * - 50px - leaving tiny amount of space so that titlebar + tiny amount of modal is visible */ transform: translateY(calc(((100vh - 100%) / 2 + 100%) - 50px)); + + @media all and (max-width: 800px) { + /* For mobile, the modal takes 100% of the available screen. + This ensures the minimized modal is always 50px above the browser bottom bar regardless of whether or not it is visible. + */ + transform: translateY(calc(100% - 50px)); + } } } @@ -27,10 +34,10 @@ @media all and (max-width: 800px) { max-width: 100vw; - height: 100vh; + height: 100%; } - .panel-body { + >.panel-body { height: 100%; overflow-y: hidden; diff --git a/src/components/settings_modal/settings_modal_content.js b/src/components/settings_modal/settings_modal_content.js index 48101a90..ef1a5ffa 100644 --- a/src/components/settings_modal/settings_modal_content.js +++ b/src/components/settings_modal/settings_modal_content.js @@ -27,6 +27,34 @@ const SettingsModalContent = { computed: { isLoggedIn () { return !!this.$store.state.users.currentUser + }, + open () { + return this.$store.state.interface.settingsModalState !== 'hidden' + } + }, + methods: { + onOpen () { + const targetTab = this.$store.state.interface.settingsModalTargetTab + // We're being told to open in specific tab + if (targetTab) { + const tabIndex = this.$refs.tabSwitcher.$slots.default.findIndex(elm => { + return elm.data && elm.data.attrs['data-tab-name'] === targetTab + }) + if (tabIndex >= 0) { + this.$refs.tabSwitcher.setTab(tabIndex) + } + } + // Clear the state of target tab, so that next time settings is opened + // it doesn't force it. + this.$store.dispatch('clearSettingsModalTargetTab') + } + }, + mounted () { + this.onOpen() + }, + watch: { + open: function (value) { + if (value) this.onOpen() } } } diff --git a/src/components/settings_modal/settings_modal_content.vue b/src/components/settings_modal/settings_modal_content.vue index 2156844f..bc30a0ff 100644 --- a/src/components/settings_modal/settings_modal_content.vue +++ b/src/components/settings_modal/settings_modal_content.vue @@ -8,6 +8,7 @@ <div :label="$t('settings.general')" icon="wrench" + data-tab-name="general" > <GeneralTab /> </div> @@ -15,6 +16,7 @@ v-if="isLoggedIn" :label="$t('settings.profile_tab')" icon="user" + data-tab-name="profile" > <ProfileTab /> </div> @@ -22,18 +24,21 @@ v-if="isLoggedIn" :label="$t('settings.security_tab')" icon="lock" + data-tab-name="security" > <SecurityTab /> </div> <div :label="$t('settings.filtering')" icon="filter" + data-tab-name="filtering" > <FilteringTab /> </div> <div :label="$t('settings.theme')" icon="brush" + data-tab-name="theme" > <ThemeTab /> </div> @@ -41,6 +46,7 @@ v-if="isLoggedIn" :label="$t('settings.notifications')" icon="bell-ringing-o" + data-tab-name="notifications" > <NotificationsTab /> </div> @@ -48,6 +54,7 @@ v-if="isLoggedIn" :label="$t('settings.data_import_export_tab')" icon="download" + data-tab-name="dataImportExport" > <DataImportExportTab /> </div> @@ -56,12 +63,14 @@ :label="$t('settings.mutes_and_blocks')" :fullHeight="true" icon="eye-off" + data-tab-name="mutesAndBlocks" > <MutesAndBlocksTab /> </div> <div :label="$t('settings.version.title')" icon="info-circled" + data-tab-name="version" > <VersionTab /> </div> diff --git a/src/components/settings_modal/tabs/filtering_tab.js b/src/components/settings_modal/tabs/filtering_tab.js index 224a7f47..3b2df556 100644 --- a/src/components/settings_modal/tabs/filtering_tab.js +++ b/src/components/settings_modal/tabs/filtering_tab.js @@ -37,6 +37,9 @@ const FilteringTab = { }) }, deep: true + }, + replyVisibility () { + this.$store.dispatch('queueFlushAll') } } } diff --git a/src/components/settings_modal/tabs/general_tab.vue b/src/components/settings_modal/tabs/general_tab.vue index f89c0480..7f06d0bd 100644 --- a/src/components/settings_modal/tabs/general_tab.vue +++ b/src/components/settings_modal/tabs/general_tab.vue @@ -54,16 +54,6 @@ </Checkbox> </li> <li> - <Checkbox v-model="autoLoad"> - {{ $t('settings.autoload') }} - </Checkbox> - </li> - <li> - <Checkbox v-model="hoverPreview"> - {{ $t('settings.reply_link_preview') }} - </Checkbox> - </li> - <li> <Checkbox v-model="emojiReactionsOnTimeline"> {{ $t('settings.emoji_reactions_on_timeline') }} </Checkbox> diff --git a/src/components/settings_modal/tabs/notifications_tab.vue b/src/components/settings_modal/tabs/notifications_tab.vue index b7a3cb37..86eed3f5 100644 --- a/src/components/settings_modal/tabs/notifications_tab.vue +++ b/src/components/settings_modal/tabs/notifications_tab.vue @@ -2,38 +2,18 @@ <div :label="$t('settings.notifications')"> <div class="setting-item"> <h2>{{ $t('settings.notification_setting_filters') }}</h2> - <div class="select-multiple"> - <span class="label">{{ $t('settings.notification_setting') }}</span> - <ul class="option-list"> - <li> - <Checkbox v-model="notificationSettings.follows"> - {{ $t('settings.notification_setting_follows') }} - </Checkbox> - </li> - <li> - <Checkbox v-model="notificationSettings.followers"> - {{ $t('settings.notification_setting_followers') }} - </Checkbox> - </li> - <li> - <Checkbox v-model="notificationSettings.non_follows"> - {{ $t('settings.notification_setting_non_follows') }} - </Checkbox> - </li> - <li> - <Checkbox v-model="notificationSettings.non_followers"> - {{ $t('settings.notification_setting_non_followers') }} - </Checkbox> - </li> - </ul> - </div> + <p> + <Checkbox v-model="notificationSettings.block_from_strangers"> + {{ $t('settings.notification_setting_block_from_strangers') }} + </Checkbox> + </p> </div> <div class="setting-item"> <h2>{{ $t('settings.notification_setting_privacy') }}</h2> <p> - <Checkbox v-model="notificationSettings.privacy_option"> - {{ $t('settings.notification_setting_privacy_option') }} + <Checkbox v-model="notificationSettings.hide_notification_contents"> + {{ $t('settings.notification_setting_hide_notification_contents') }} </Checkbox> </p> </div> 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 d1f044f6..281052e5 100644 --- a/src/components/side_drawer/side_drawer.js +++ b/src/components/side_drawer/side_drawer.js @@ -1,3 +1,4 @@ +import { mapState, mapGetters } from 'vuex' import UserCard from '../user_card/user_card.vue' import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils' import GestureService from '../../services/gesture_service/gesture_service' @@ -47,7 +48,17 @@ const SideDrawer = { }, federating () { return this.$store.state.instance.federating - } + }, + timelinesRoute () { + if (this.$store.state.interface.lastTimeline) { + return this.$store.state.interface.lastTimeline + } + 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 f253742d..0587ee02 100644 --- a/src/components/side_drawer/side_drawer.vue +++ b/src/components/side_drawer/side_drawer.vue @@ -40,33 +40,39 @@ </router-link> </li> <li - v-if="currentUser" + v-if="currentUser || !privateMode" @click="toggleDrawer" > - <router-link :to="{ name: 'dms', params: { username: currentUser.screen_name } }"> - <i class="button-icon icon-mail-alt" /> {{ $t("nav.dms") }} + <router-link :to="{ name: timelinesRoute }"> + <i class="button-icon icon-home-2" /> {{ $t("nav.timelines") }} </router-link> </li> <li - v-if="currentUser" + v-if="currentUser && pleromaChatMessagesAvailable" @click="toggleDrawer" > - <router-link :to="{ name: 'interactions', params: { username: currentUser.screen_name } }"> - <i class="button-icon icon-bell-alt" /> {{ $t("nav.interactions") }} + <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> - <li - v-if="currentUser" - @click="toggleDrawer" - > - <router-link :to="{ name: 'friends' }"> - <i class="button-icon icon-home-2" /> {{ $t("nav.timeline") }} + <ul v-if="currentUser"> + <li @click="toggleDrawer"> + <router-link :to="{ name: 'interactions', params: { username: currentUser.screen_name } }"> + <i class="button-icon icon-bell-alt" /> {{ $t("nav.interactions") }} </router-link> </li> <li - v-if="currentUser && currentUser.locked" + v-if="currentUser.locked" @click="toggleDrawer" > <router-link to="/friend-requests"> @@ -80,23 +86,7 @@ </router-link> </li> <li - 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") }} - </router-link> - </li> - <li - v-if="currentUser && chat" + v-if="chat" @click="toggleDrawer" > <router-link :to="{ name: 'chat' }"> diff --git a/src/components/staff_panel/staff_panel.js b/src/components/staff_panel/staff_panel.js index 4f98fff6..8665648a 100644 --- a/src/components/staff_panel/staff_panel.js +++ b/src/components/staff_panel/staff_panel.js @@ -2,6 +2,10 @@ import map from 'lodash/map' import BasicUserCard from '../basic_user_card/basic_user_card.vue' const StaffPanel = { + created () { + const nicknames = this.$store.state.instance.staffAccounts + nicknames.forEach(nickname => this.$store.dispatch('fetchUserIfMissing', nickname)) + }, components: { BasicUserCard }, diff --git a/src/components/status/status.js b/src/components/status/status.js index 73382521..d263da68 100644 --- a/src/components/status/status.js +++ b/src/components/status/status.js @@ -9,6 +9,7 @@ import AvatarList from '../avatar_list/avatar_list.vue' import Timeago from '../timeago/timeago.vue' import StatusContent from '../status_content/status_content.vue' import StatusPopover from '../status_popover/status_popover.vue' +import UserListPopover from '../user_list_popover/user_list_popover.vue' import EmojiReactions from '../emoji_reactions/emoji_reactions.vue' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js' @@ -18,6 +19,21 @@ import { mapGetters, mapState } from 'vuex' const Status = { name: 'Status', + components: { + FavoriteButton, + ReactButton, + RetweetButton, + ExtraButtons, + PostStatusForm, + UserCard, + UserAvatar, + AvatarList, + Timeago, + StatusPopover, + UserListPopover, + EmojiReactions, + StatusContent + }, props: [ 'statusoid', 'expandable', @@ -141,7 +157,7 @@ const Status = { return this.mergedConfig.hideFilteredStatuses }, hideStatus () { - return (this.hideReply || this.deleted) || (this.muted && this.hideFilteredStatuses) + return this.deleted || (this.muted && this.hideFilteredStatuses) }, isFocused () { // retweet or root of an expanded conversation @@ -164,37 +180,6 @@ const Status = { return user && user.screen_name } }, - hideReply () { - if (this.mergedConfig.replyVisibility === 'all') { - return false - } - if (this.inConversation || !this.isReply) { - return false - } - if (this.status.user.id === this.currentUser.id) { - return false - } - if (this.status.type === 'retweet') { - return false - } - const checkFollowing = this.mergedConfig.replyVisibility === 'following' - for (var i = 0; i < this.status.attentions.length; ++i) { - if (this.status.user.id === this.status.attentions[i].id) { - continue - } - // There's zero guarantee of this working. If we happen to have that user and their - // relationship in store then it will work, but there's kinda little chance of having - // them for people you're not following. - const relationship = this.$store.state.users.relationships[this.status.attentions[i].id] - if (checkFollowing && relationship && relationship.following) { - return false - } - if (this.status.attentions[i].id === this.currentUser.id) { - return false - } - } - return this.status.attentions.length > 0 - }, replySubject () { if (!this.status.summary) return '' const decodedSummary = unescape(this.status.summary) @@ -228,20 +213,6 @@ const Status = { currentUser: state => state.users.currentUser }) }, - components: { - FavoriteButton, - ReactButton, - RetweetButton, - ExtraButtons, - PostStatusForm, - UserCard, - UserAvatar, - AvatarList, - Timeago, - StatusPopover, - EmojiReactions, - StatusContent - }, methods: { visibilityIcon (visibility) { switch (visibility) { diff --git a/src/components/status/status.scss b/src/components/status/status.scss new file mode 100644 index 00000000..8d292d3f --- /dev/null +++ b/src/components/status/status.scss @@ -0,0 +1,414 @@ + +@import '../../_variables.scss'; + +$status-margin: 0.75em; + +.Status { + min-width: 0; + + &:hover { + --still-image-img: visible; + --still-image-canvas: hidden; + } + + &.-focused { + background-color: $fallback--lightBg; + background-color: var(--selectedPost, $fallback--lightBg); + color: $fallback--text; + color: var(--selectedPostText, $fallback--text); + + --lightText: var(--selectedPostLightText, $fallback--light); + --faint: var(--selectedPostFaintText, $fallback--faint); + --faintLink: var(--selectedPostFaintLink, $fallback--faint); + --postLink: var(--selectedPostPostLink, $fallback--faint); + --postFaintLink: var(--selectedPostFaintPostLink, $fallback--faint); + --icon: var(--selectedPostIcon, $fallback--icon); + } + + .status-container { + display: flex; + padding: $status-margin; + + &.-repeat { + padding-top: 0; + } + } + + .pin { + padding: $status-margin $status-margin 0; + display: flex; + align-items: center; + justify-content: flex-end; + } + + .left-side { + margin-right: $status-margin; + } + + .right-side { + flex: 1; + min-width: 0; + } + + .usercard { + margin-bottom: $status-margin; + } + + .status-username { + white-space: nowrap; + font-size: 14px; + overflow: hidden; + max-width: 85%; + font-weight: bold; + flex-shrink: 1; + margin-right: 0.4em; + text-overflow: ellipsis; + + .emoji { + width: 14px; + height: 14px; + vertical-align: middle; + object-fit: contain; + } + } + + .status-favicon { + height: 18px; + width: 18px; + margin-right: 0.4em; + } + + .status-heading { + margin-bottom: 0.5em; + } + + .heading-name-row { + display: flex; + justify-content: space-between; + line-height: 18px; + + a { + display: inline-block; + word-break: break-all; + } + } + + .account-name { + min-width: 1.6em; + margin-right: 0.4em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1 1 0; + } + + .heading-left { + display: flex; + min-width: 0; + } + + .heading-right { + display: flex; + flex-shrink: 0; + } + + .timeago { + margin-right: 0.2em; + } + + .heading-reply-row { + position: relative; + align-content: baseline; + font-size: 12px; + line-height: 18px; + max-width: 100%; + display: flex; + flex-wrap: wrap; + align-items: stretch; + } + + .reply-to-and-accountname { + display: flex; + height: 18px; + margin-right: 0.5em; + max-width: 100%; + + .reply-to-link { + white-space: nowrap; + word-break: break-word; + text-overflow: ellipsis; + overflow-x: hidden; + } + + .icon-reply { + // mirror the icon + transform: scaleX(-1); + } + } + + & .reply-to-popover, + & .reply-to-no-popover { + min-width: 0; + margin-right: 0.4em; + flex-shrink: 0; + } + + .reply-to-popover { + .reply-to:hover::before { + content: ''; + display: block; + position: absolute; + bottom: 0; + width: 100%; + border-bottom: 1px solid var(--faint); + pointer-events: none; + } + + .faint-link:hover { + // override default + text-decoration: none; + } + + &.-strikethrough { + .reply-to::after { + content: ''; + display: block; + position: absolute; + top: 50%; + width: 100%; + border-bottom: 1px solid var(--faint); + pointer-events: none; + } + } + } + + .reply-to { + display: flex; + position: relative; + } + + .reply-to-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-left: 0.2em; + } + + .replies-separator { + margin-left: 0.4em; + } + + .replies { + line-height: 18px; + font-size: 12px; + display: flex; + flex-wrap: wrap; + + & > * { + margin-right: 0.4em; + } + } + + .reply-link { + height: 17px; + } + + .repeat-info { + padding: 0.4em $status-margin; + line-height: 22px; + + .right-side { + display: flex; + align-content: center; + flex-wrap: wrap; + } + + i { + padding: 0 0.2em; + } + } + + .repeater-avatar { + border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius); + margin-left: 28px; + width: 20px; + height: 20px; + } + + .repeater-name { + text-overflow: ellipsis; + margin-right: 0; + + .emoji { + width: 14px; + height: 14px; + vertical-align: middle; + object-fit: contain; + } + } + + .status-fadein { + animation-duration: 0.4s; + animation-name: fadein; + } + + @keyframes fadein { + from { + opacity: 0; + } + + to { + opacity: 1; + } + } + + .status-actions { + position: relative; + width: 100%; + display: flex; + margin-top: $status-margin; + + > * { + max-width: 4em; + flex: 1; + } + } + + .button-reply { + &:not(.-disabled) { + cursor: pointer; + } + + &:not(.-disabled):hover, + &.-active { + color: $fallback--cBlue; + color: var(--cBlue, $fallback--cBlue); + } + } + + .muted { + padding: 0.25em 0.6em; + height: 1.2em; + line-height: 1.2em; + text-overflow: ellipsis; + overflow: hidden; + display: flex; + flex-wrap: nowrap; + + & .status-username, + & .mute-thread, + & .mute-words { + word-wrap: normal; + word-break: normal; + white-space: nowrap; + } + + & .status-username, + & .mute-words { + text-overflow: ellipsis; + overflow: hidden; + } + + .status-username { + font-weight: normal; + flex: 0 1 auto; + margin-right: 0.2em; + font-size: smaller; + } + + .mute-thread { + flex: 0 0 auto; + } + + .mute-words { + flex: 1 0 5em; + margin-left: 0.2em; + + &::before { + content: ' '; + } + } + + .unmute { + flex: 0 0 auto; + margin-left: auto; + display: block; + } + } + + .reply-form { + padding-top: 0; + padding-bottom: 0; + } + + .reply-body { + flex: 1; + } + + .favs-repeated-users { + margin-top: $status-margin; + } + + .stats { + width: 100%; + display: flex; + line-height: 1em; + } + + .avatar-row { + flex: 1; + overflow: hidden; + position: relative; + display: flex; + align-items: center; + + &::before { + content: ''; + position: absolute; + height: 100%; + width: 1px; + left: 0; + background-color: var(--faint, $fallback--faint); + } + } + + .stat-count { + margin-right: $status-margin; + user-select: none; + + .stat-title { + color: var(--faint, $fallback--faint); + font-size: 12px; + text-transform: uppercase; + position: relative; + } + + .stat-number { + font-weight: bolder; + font-size: 16px; + line-height: 1em; + } + + &:hover .stat-title { + text-decoration: underline; + } + } + + @media all and (max-width: 800px) { + .repeater-avatar { + margin-left: 20px; + } + + .avatar:not(.repeater-avatar) { + width: 40px; + height: 40px; + + // TODO define those other way somehow? + // stylelint-disable rscss/class-format + &.avatar-compact { + width: 32px; + height: 32px; + } + } + } +} diff --git a/src/components/status/status.vue b/src/components/status/status.vue index 7ec29b28..282ad37d 100644 --- a/src/components/status/status.vue +++ b/src/components/status/status.vue @@ -2,8 +2,8 @@ <!-- eslint-disable vue/no-v-html --> <div v-if="!hideStatus" - class="status-el" - :class="[{ 'status-el_focused': isFocused }, { 'status-conversation': inlineExpanded }]" + class="Status" + :class="[{ '-focused': isFocused }, { '-conversation': inlineExpanded }]" > <div v-if="error" @@ -16,8 +16,8 @@ /> </div> <template v-if="muted && !isPreview"> - <div class="media status container muted"> - <small class="username"> + <div class="status-csontainer muted"> + <small class="status-username"> <i v-if="muted && retweet" class="button-icon icon-retweet" @@ -54,7 +54,7 @@ <template v-else> <div v-if="showPinned" - class="status-pin" + class="pin" > <i class="fa icon-pin faint" /> <span class="faint">{{ $t('status.pinned') }}</span> @@ -63,16 +63,19 @@ v-if="retweet && !noHeading && !inConversation" :class="[repeaterClass, { highlighted: repeaterStyle }]" :style="[repeaterStyle]" - class="media container retweet-info" + class="status-container repeat-info" > <UserAvatar v-if="retweet" - class="media-left" + class="left-side repeater-avatar" :better-shadow="betterShadow" :user="statusoid.user" /> - <div class="media-body faint"> - <span class="user-name"> + <div class="right-side faint"> + <span + class="status-username repeater-name" + :title="retweeter" + > <router-link v-if="retweeterHtml" :to="retweeterProfileLink" @@ -92,14 +95,14 @@ </div> <div - :class="[userClass, { highlighted: userStyle, 'is-retweet': retweet && !inConversation }]" + :class="[userClass, { highlighted: userStyle, '-repeat': retweet && !inConversation }]" :style="[ userStyle ]" - class="media status" + class="status-container" :data-tags="tags" > <div v-if="!noHeading" - class="media-left" + class="left-side" > <router-link :to="userProfileLink" @@ -112,37 +115,45 @@ /> </router-link> </div> - <div class="status-body"> + <div class="right-side"> <UserCard v-if="userExpanded" :user-id="status.user.id" :rounded="true" :bordered="true" - class="status-usercard" + class="usercard" /> <div v-if="!noHeading" - class="media-heading" + class="status-heading" > <div class="heading-name-row"> - <div class="name-and-account-name"> + <div class="heading-left"> <h4 v-if="status.user.name_html" - class="user-name" + class="status-username" + :title="status.user.name" v-html="status.user.name_html" /> <h4 v-else - class="user-name" + class="status-username" + :title="status.user.name" > {{ status.user.name }} </h4> <router-link class="account-name" + :title="status.user.screen_name" :to="userProfileLink" > {{ status.user.screen_name }} </router-link> + <img + v-if="!!(status.user && status.user.favicon)" + class="status-favicon" + :src="status.user.favicon" + > </div> <span class="heading-right"> @@ -197,9 +208,10 @@ > <StatusPopover v-if="!isPreview" - :status-id="status.in_reply_to_status_id" + :status-id="status.parent_visible && status.in_reply_to_status_id" class="reply-to-popover" style="min-width: 0" + :class="{ '-strikethrough': !status.parent_visible }" > <a class="reply-to" @@ -207,17 +219,25 @@ :aria-label="$t('tool_tip.reply')" @click.prevent="gotoOriginal(status.in_reply_to_status_id)" > - <i class="button-icon icon-reply" /> - <span class="faint-link reply-to-text">{{ $t('status.reply_to') }}</span> + <i class="button-icon reply-button icon-reply" /> + <span + class="faint-link reply-to-text" + > + {{ $t('status.reply_to') }} + </span> </a> </StatusPopover> <span v-else - class="reply-to" + class="reply-to-no-popover" > <span class="reply-to-text">{{ $t('status.reply_to') }}</span> </span> - <router-link :to="replyProfileLink"> + <router-link + class="reply-to-link" + :title="replyToName" + :to="replyProfileLink" + > {{ replyToName }} </router-link> <span @@ -260,24 +280,30 @@ class="favs-repeated-users" > <div class="stats"> - <div + <UserListPopover v-if="statusFromGlobalRepository.rebloggedBy && statusFromGlobalRepository.rebloggedBy.length > 0" - class="stat-count" + :users="statusFromGlobalRepository.rebloggedBy" > - <a class="stat-title">{{ $t('status.repeats') }}</a> - <div class="stat-number"> - {{ statusFromGlobalRepository.rebloggedBy.length }} + <div class="stat-count"> + <a class="stat-title">{{ $t('status.repeats') }}</a> + <div class="stat-number"> + {{ statusFromGlobalRepository.rebloggedBy.length }} + </div> </div> - </div> - <div + </UserListPopover> + <UserListPopover v-if="statusFromGlobalRepository.favoritedBy && statusFromGlobalRepository.favoritedBy.length > 0" - class="stat-count" + :users="statusFromGlobalRepository.favoritedBy" > - <a class="stat-title">{{ $t('status.favorites') }}</a> - <div class="stat-number"> - {{ statusFromGlobalRepository.favoritedBy.length }} + <div + class="stat-count" + > + <a class="stat-title">{{ $t('status.favorites') }}</a> + <div class="stat-number"> + {{ statusFromGlobalRepository.favoritedBy.length }} + </div> </div> - </div> + </UserListPopover> <div class="avatar-row"> <AvatarList :users="combinedFavsAndRepeatsUsers" /> </div> @@ -292,19 +318,19 @@ <div v-if="!noHeading && !isPreview" - class="status-actions media-body" + class="status-actions" > <div> <i v-if="loggedIn" - class="button-icon icon-reply" + class="button-icon button-reply icon-reply" :title="$t('tool_tip.reply')" - :class="{'button-icon-active': replying}" + :class="{'-active': replying}" @click.prevent="toggleReplying" /> <i v-else - class="button-icon button-icon-disabled icon-reply" + class="button-icon button-reply -disabled icon-reply" :title="$t('tool_tip.reply')" /> <span v-if="status.replies_count > 0">{{ status.replies_count }}</span> @@ -332,7 +358,7 @@ </div> <div v-if="replying" - class="container" + class="status-container reply-form" > <PostStatusForm class="reply-body" @@ -350,427 +376,4 @@ </template> <script src="./status.js" ></script> -<style lang="scss"> -@import '../../_variables.scss'; - -$status-margin: 0.75em; - -.status-body { - flex: 1; - min-width: 0; -} - -.status-pin { - padding: $status-margin $status-margin 0; - display: flex; - align-items: center; - justify-content: flex-end; -} - -.media-left { - margin-right: $status-margin; -} - -.status-el { - overflow-wrap: break-word; - word-wrap: break-word; - word-break: break-word; - border-left-width: 0px; - min-width: 0; - border-color: $fallback--border; - border-color: var(--border, $fallback--border); - - border-left: 4px $fallback--cRed; - border-left: 4px var(--cRed, $fallback--cRed); - - &_focused { - background-color: $fallback--lightBg; - background-color: var(--selectedPost, $fallback--lightBg); - color: $fallback--text; - color: var(--selectedPostText, $fallback--text); - --lightText: var(--selectedPostLightText, $fallback--light); - --faint: var(--selectedPostFaintText, $fallback--faint); - --faintLink: var(--selectedPostFaintLink, $fallback--faint); - --postLink: var(--selectedPostPostLink, $fallback--faint); - --postFaintLink: var(--selectedPostFaintPostLink, $fallback--faint); - --icon: var(--selectedPostIcon, $fallback--icon); - } - - .timeline & { - border-bottom-width: 1px; - border-bottom-style: solid; - } - - .media-body { - flex: 1; - padding: 0; - } - - .status-usercard { - margin-bottom: $status-margin; - } - - .user-name { - white-space: nowrap; - font-size: 14px; - overflow: hidden; - flex-shrink: 0; - max-width: 85%; - font-weight: bold; - - img.emoji { - width: 14px; - height: 14px; - vertical-align: middle; - object-fit: contain - } - } - - .media-heading { - padding: 0; - vertical-align: bottom; - flex-basis: 100%; - margin-bottom: 0.5em; - - small { - font-weight: lighter; - } - - .heading-name-row { - padding: 0; - display: flex; - justify-content: space-between; - line-height: 18px; - - a { - display: inline-block; - word-break: break-all; - } - - .name-and-account-name { - display: flex; - min-width: 0; - } - - .user-name { - flex-shrink: 1; - margin-right: 0.4em; - overflow: hidden; - text-overflow: ellipsis; - } - - .account-name { - min-width: 1.6em; - margin-right: 0.4em; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - flex: 1 1 0; - } - } - - .heading-right { - display: flex; - flex-shrink: 0; - } - - .timeago { - margin-right: 0.2em; - } - - .heading-reply-row { - position: relative; - align-content: baseline; - font-size: 12px; - line-height: 18px; - max-width: 100%; - display: flex; - flex-wrap: wrap; - align-items: stretch; - - > .reply-to-and-accountname > a { - overflow: hidden; - max-width: 100%; - text-overflow: ellipsis; - white-space: nowrap; - word-break: break-all; - } - } - - .reply-to-and-accountname { - display: flex; - height: 18px; - margin-right: 0.5em; - max-width: 100%; - .icon-reply { - transform: scaleX(-1); - } - } - - .reply-info { - display: flex; - } - - .reply-to-popover { - min-width: 0; - } - - .reply-to { - display: flex; - } - - .reply-to-text { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - margin: 0 0.4em 0 0.2em; - } - - .replies-separator { - margin-left: 0.4em; - } - - .replies { - line-height: 18px; - font-size: 12px; - display: flex; - flex-wrap: wrap; - & > * { - margin-right: 0.4em; - } - } - - .reply-link { - height: 17px; - } - } - - .retweet-info { - padding: 0.4em $status-margin; - margin: 0; - - .avatar.still-image { - border-radius: $fallback--avatarAltRadius; - border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius); - margin-left: 28px; - width: 20px; - height: 20px; - } - - .media-body { - font-size: 1em; - line-height: 22px; - - display: flex; - align-content: center; - flex-wrap: wrap; - - .user-name { - font-weight: bold; - overflow: hidden; - text-overflow: ellipsis; - - img { - width: 14px; - height: 14px; - vertical-align: middle; - object-fit: contain - } - } - - i { - padding: 0 0.2em; - } - - a { - max-width: 100%; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - } - } -} - -.status-fadein { - animation-duration: 0.4s; - animation-name: fadein; -} - -@keyframes fadein { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -.status-conversation { - border-left-style: solid; -} - -.status-actions { - position: relative; - width: 100%; - display: flex; - margin-top: $status-margin; - - > * { - max-width: 4em; - flex: 1; - } -} - -.button-icon.icon-reply { - &:not(.button-icon-disabled):hover, - &.button-icon-active { - color: $fallback--cBlue; - color: var(--cBlue, $fallback--cBlue); - } -} - -.button-icon.icon-reply { - &:not(.button-icon-disabled) { - cursor: pointer; - } -} - -.status:hover .animated.avatar { - canvas { - display: none; - } - img { - visibility: visible; - } -} - -.status { - display: flex; - padding: $status-margin; - &.is-retweet { - padding-top: 0; - } -} - -.status-conversation:last-child { - border-bottom: none; -} - -.muted { - padding: .25em .6em; - height: 1.2em; - line-height: 1.2em; - text-overflow: ellipsis; - overflow: hidden; - display: flex; - flex-wrap: nowrap; - - .username, .mute-thread, .mute-words { - word-wrap: normal; - word-break: normal; - white-space: nowrap; - } - - .username, .mute-words { - text-overflow: ellipsis; - overflow: hidden; - } - - .username { - flex: 0 1 auto; - margin-right: .2em; - } - - .mute-thread { - flex: 0 0 auto; - } - - .mute-words { - flex: 1 0 5em; - margin-left: .2em; - &::before { - content: ' ' - } - } - - .unmute { - flex: 0 0 auto; - margin-left: auto; - display: block; - margin-left: auto; - } -} - -.reply-body { - flex: 1; -} - -.favs-repeated-users { - margin-top: $status-margin; - - .stats { - width: 100%; - display: flex; - line-height: 1em; - - .stat-count { - margin-right: $status-margin; - - .stat-title { - color: var(--faint, $fallback--faint); - font-size: 12px; - text-transform: uppercase; - position: relative; - } - - .stat-number { - font-weight: bolder; - font-size: 16px; - line-height: 1em; - } - } - - .avatar-row { - flex: 1; - overflow: hidden; - position: relative; - display: flex; - align-items: center; - - &::before { - content: ''; - position: absolute; - height: 100%; - width: 1px; - left: 0; - background-color: var(--faint, $fallback--faint); - } - } - } -} - -@media all and (max-width: 800px) { - .status-el { - .retweet-info { - .avatar.still-image { - margin-left: 20px; - } - } - } - .status { - max-width: 100%; - } - - .status .avatar.still-image { - width: 40px; - height: 40px; - - &.avatar-compact { - width: 32px; - height: 32px; - } - } -} - -</style> +<style src="./status.scss" lang="scss"></style> diff --git a/src/components/status_content/status_content.js b/src/components/status_content/status_content.js index c0a71e8f..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 @@ -44,14 +45,14 @@ const StatusContent = { return lengthScore > 20 }, longSubject () { - return this.status.summary.length > 900 + return this.status.summary.length > 240 }, // When a status has a subject and is also tall, we should only have one show more/less button. If the default is to collapse statuses with subjects, we just treat it like a status with a subject; otherwise, we just treat it like a tall status. mightHideBecauseSubject () { - return this.status.summary && (!this.tallStatus || this.localCollapseSubjectDefault) + return !!this.status.summary && this.localCollapseSubjectDefault }, mightHideBecauseTall () { - return this.tallStatus && (!this.status.summary || !this.localCollapseSubjectDefault) + return this.tallStatus && !(this.status.summary && this.localCollapseSubjectDefault) }, hideSubjectStatus () { return this.mightHideBecauseSubject && !this.expandingSubject @@ -99,15 +100,8 @@ const StatusContent = { file => !fileType.fileMatchesSomeType(this.galleryTypes, file) ) }, - hasImageAttachments () { - return this.status.attachments.some( - file => fileType.fileType(file.mimetype) === 'image' - ) - }, - hasVideoAttachments () { - return this.status.attachments.some( - file => fileType.fileType(file.mimetype) === 'video' - ) + attachmentTypes () { + return this.status.attachments.map(file => fileType.fileType(file.mimetype)) }, maxThumbnails () { return this.mergedConfig.maxThumbnails @@ -142,12 +136,6 @@ const StatusContent = { return html } }, - contentHtml () { - if (!this.status.summary_html) { - return this.postBodyHtml - } - return this.status.summary_html + '<br />' + this.postBodyHtml - }, ...mapGetters(['mergedConfig']), ...mapState({ betterShadow: state => state.interface.browserSupport.cssFilter, diff --git a/src/components/status_content/status_content.vue b/src/components/status_content/status_content.vue index efc2485e..76fe3278 100644 --- a/src/components/status_content/status_content.vue +++ b/src/components/status_content/status_content.vue @@ -1,47 +1,34 @@ <template> <!-- eslint-disable vue/no-v-html --> - <div class="status-body"> + <div class="StatusContent"> <slot name="header" /> <div - v-if="longSubject" - class="status-content-wrapper" - :class="{ 'tall-status': !showingLongSubject }" + v-if="status.summary_html" + class="summary-wrapper" + :class="{ 'tall-subject': (longSubject && !showingLongSubject) }" > - <a - v-if="!showingLongSubject" - class="tall-status-hider" - :class="{ 'tall-status-hider_focused': focused }" - href="#" - @click.prevent="showingLongSubject=true" - > - {{ $t("general.show_more") }} - <span - v-if="hasImageAttachments" - class="icon-picture" - /> - <span - v-if="hasVideoAttachments" - class="icon-video" - /> - <span - v-if="status.card" - class="icon-link" - /> - </a> <div - class="status-content media-body" + class="media-body summary" @click.prevent="linkClicked" - v-html="contentHtml" + v-html="status.summary_html" /> <a - v-if="showingLongSubject" + v-if="longSubject && showingLongSubject" href="#" - class="status-unhider" + class="tall-subject-hider" @click.prevent="showingLongSubject=false" - >{{ $t("general.show_less") }}</a> + >{{ $t("status.hide_full_subject") }}</a> + <a + v-else-if="longSubject" + class="tall-subject-hider" + :class="{ 'tall-subject-hider_focused': focused }" + href="#" + @click.prevent="showingLongSubject=true" + > + {{ $t("status.show_full_subject") }} + </a> </div> <div - v-else :class="{'tall-status': hideTallStatus}" class="status-content-wrapper" > @@ -51,34 +38,59 @@ :class="{ 'tall-status-hider_focused': focused }" href="#" @click.prevent="toggleShowMore" - >{{ $t("general.show_more") }}</a> + > + {{ $t("general.show_more") }} + </a> <div v-if="!hideSubjectStatus" + :class="{ 'single-line': singleLine }" class="status-content media-body" @click.prevent="linkClicked" - v-html="contentHtml" - /> - <div - v-else - class="status-content media-body" - @click.prevent="linkClicked" - v-html="status.summary_html" + v-html="postBodyHtml" /> <a v-if="hideSubjectStatus" href="#" class="cw-status-hider" @click.prevent="toggleShowMore" - >{{ $t("general.show_more") }}</a> + > + {{ $t("status.show_content") }} + <span + v-if="attachmentTypes.includes('image')" + class="icon-picture" + /> + <span + v-if="attachmentTypes.includes('video')" + class="icon-video" + /> + <span + v-if="attachmentTypes.includes('audio')" + class="icon-music" + /> + <span + v-if="attachmentTypes.includes('unknown')" + class="icon-doc" + /> + <span + v-if="status.poll && status.poll.options" + class="icon-chart-bar" + /> + <span + v-if="status.card" + class="icon-link" + /> + </a> <a - v-if="showingMore" + v-if="showingMore && !fullContent" href="#" class="status-unhider" @click.prevent="toggleShowMore" - >{{ $t("general.show_less") }}</a> + > + {{ tallStatus ? $t("general.show_less") : $t("status.hide_content") }} + </a> </div> - <div v-if="status.poll && status.poll.options"> + <div v-if="status.poll && status.poll.options && !hideSubjectStatus"> <poll :base-poll="status.poll" /> </div> @@ -125,10 +137,16 @@ $status-margin: 0.75em; -.status-body { +.StatusContent { flex: 1; min-width: 0; + .status-content-wrapper { + display: flex; + flex-direction: column; + flex-wrap: nowrap; + } + .tall-status { position: relative; height: 220px; @@ -136,7 +154,7 @@ $status-margin: 0.75em; overflow-y: hidden; z-index: 1; .status-content { - height: 100%; + min-height: 0; mask: linear-gradient(to top, white, transparent) bottom/100% 70px no-repeat, linear-gradient(to top, white, white); /* Autoprefixed seem to ignore this one, and also syntax is different */ @@ -176,10 +194,45 @@ $status-margin: 0.75em; } } + .summary-wrapper { + margin-bottom: 0.5em; + border-style: solid; + border-width: 0 0 1px 0; + border-color: var(--border, $fallback--border); + flex-grow: 0; + } + + .summary { + font-style: italic; + padding-bottom: 0.5em; + } + + .tall-subject { + position: relative; + .summary { + max-height: 2em; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + } + + .tall-subject-hider { + display: inline-block; + word-break: break-all; + // position: absolute; + width: 100%; + text-align: center; + padding-bottom: 0.5em; + } + .status-content { font-family: var(--postFont, sans-serif); line-height: 1.4em; white-space: pre-wrap; + overflow-wrap: break-word; + word-wrap: break-word; + word-break: break-word; blockquote { margin: 0.2em 0 0.2em 2em; @@ -221,6 +274,13 @@ $status-margin: 0.75em; h4 { margin: 1.1em 0; } + + &.single-line { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + height: 1.4em; + } } } @@ -228,13 +288,4 @@ $status-margin: 0.75em; color: $fallback--cGreen; color: var(--postGreentext, $fallback--cGreen); } - -.timeline :not(.panel-disabled) > { - .status-el:last-child { - border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius; - border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius); - border-bottom: none; - } -} - </style> diff --git a/src/components/status_popover/status_popover.js b/src/components/status_popover/status_popover.js index 159132a9..51e7680c 100644 --- a/src/components/status_popover/status_popover.js +++ b/src/components/status_popover/status_popover.js @@ -22,6 +22,10 @@ const StatusPopover = { methods: { enter () { if (!this.status) { + if (!this.statusId) { + this.error = true + return + } this.$store.dispatch('fetchStatus', this.statusId) .then(data => (this.error = false)) .catch(e => (this.error = true)) diff --git a/src/components/status_popover/status_popover.vue b/src/components/status_popover/status_popover.vue index f5948207..162eb210 100644 --- a/src/components/status_popover/status_popover.vue +++ b/src/components/status_popover/status_popover.vue @@ -1,7 +1,7 @@ <template> <Popover trigger="hover" - popover-class="status-popover" + popover-class="popover-default status-popover" :bound-to="{ x: 'container' }" @show="enter" > @@ -38,7 +38,8 @@ <style lang="scss"> @import '../../_variables.scss'; -.status-popover { +/* popover styles load on-demand, so we need to override */ +.status-popover.popover { font-size: 1rem; min-width: 15em; max-width: 95%; @@ -52,7 +53,8 @@ box-shadow: 2px 2px 3px rgba(0, 0, 0, 0.5); box-shadow: var(--popupShadow); - .status-el.status-el { + /* TODO cleanup this */ + .Status.Status { border: none; } diff --git a/src/components/still-image/still-image.js b/src/components/still-image/still-image.js index e48fef47..ab40bbd7 100644 --- a/src/components/still-image/still-image.js +++ b/src/components/still-image/still-image.js @@ -4,7 +4,8 @@ const StillImage = { 'referrerpolicy', 'mimetype', 'imageLoadError', - 'imageLoadHandler' + 'imageLoadHandler', + 'alt' ], data () { return { diff --git a/src/components/still-image/still-image.vue b/src/components/still-image/still-image.vue index f2ddeb7b..ad82210d 100644 --- a/src/components/still-image/still-image.vue +++ b/src/components/still-image/still-image.vue @@ -11,6 +11,8 @@ <img ref="src" :key="src" + :alt="alt" + :title="alt" :src="src" :referrerpolicy="referrerpolicy" @load="onLoad" @@ -28,13 +30,19 @@ position: relative; line-height: 0; overflow: hidden; - width: 100%; - height: 100%; display: flex; align-items: center; - &:hover canvas { - display: none; + canvas { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + width: 100%; + height: 100%; + object-fit: contain; + visibility: var(--still-image-canvas, visible); } img { @@ -44,15 +52,6 @@ } &.animated { - &:hover::before, - img { - visibility: hidden; - } - - &:hover img { - visibility: visible - } - &::before { content: 'gif'; position: absolute; @@ -60,25 +59,28 @@ font-size: 10px; top: 5px; left: 5px; - background: rgba(127,127,127,.5); - color: #FFF; + background: rgba(127, 127, 127, 0.5); + color: #fff; display: block; padding: 2px 4px; border-radius: $fallback--tooltipRadius; border-radius: var(--tooltipRadius, $fallback--tooltipRadius); z-index: 2; + visibility: var(--still-image-label-visibility, visible); } - } - canvas { - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - width: 100%; - height: 100%; - object-fit: contain; + &:hover canvas { + display: none; + } + + &:hover::before, + img { + visibility: var(--still-image-img, hidden); + } + + &:hover img { + visibility: visible; + } } } </style> diff --git a/src/components/tab_switcher/tab_switcher.js b/src/components/tab_switcher/tab_switcher.js index 7891cb78..9c1da354 100644 --- a/src/components/tab_switcher/tab_switcher.js +++ b/src/components/tab_switcher/tab_switcher.js @@ -1,4 +1,5 @@ import Vue from 'vue' +import { mapState } from 'vuex' import './tab_switcher.scss' @@ -44,7 +45,13 @@ export default Vue.component('tab-switcher', { } else { return this.active } - } + }, + settingsModalVisible () { + return this.settingsModalState === 'visible' + }, + ...mapState({ + settingsModalState: state => state.interface.settingsModalState + }) }, beforeUpdate () { const currentSlot = this.$slots.default[this.active] @@ -53,16 +60,19 @@ export default Vue.component('tab-switcher', { } }, methods: { - activateTab (index) { + clickTab (index) { return (e) => { e.preventDefault() - if (typeof this.onSwitch === 'function') { - this.onSwitch.call(null, this.$slots.default[index].key) - } - this.active = index - if (this.scrollableTabs) { - this.$refs.contents.scrollTop = 0 - } + this.setTab(index) + } + }, + setTab (index) { + if (typeof this.onSwitch === 'function') { + this.onSwitch.call(null, this.$slots.default[index].key) + } + this.active = index + if (this.scrollableTabs) { + this.$refs.contents.scrollTop = 0 } } }, @@ -81,7 +91,7 @@ export default Vue.component('tab-switcher', { <div class={classesWrapper.join(' ')}> <button disabled={slot.data.attrs.disabled} - onClick={this.activateTab(index)} + onClick={this.clickTab(index)} class={classesTab.join(' ')}> <img src={slot.data.attrs.image} title={slot.data.attrs['image-tooltip']}/> {slot.data.attrs.label ? '' : slot.data.attrs.label} @@ -93,7 +103,7 @@ export default Vue.component('tab-switcher', { <div class={classesWrapper.join(' ')}> <button disabled={slot.data.attrs.disabled} - onClick={this.activateTab(index)} + onClick={this.clickTab(index)} class={classesTab.join(' ')} type="button" > @@ -134,7 +144,7 @@ export default Vue.component('tab-switcher', { <div class="tabs"> {tabs} </div> - <div ref="contents" class={'contents' + (this.scrollableTabs ? ' scrollable-tabs' : '')}> + <div ref="contents" class={'contents' + (this.scrollableTabs ? ' scrollable-tabs' : '')} v-body-scroll-lock={this.settingsModalVisible}> {contents} </div> </div> diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js index 9a53acd6..5a7f7a78 100644 --- a/src/components/timeline/timeline.js +++ b/src/components/timeline/timeline.js @@ -1,6 +1,7 @@ import Status from '../status/status.vue' import timelineFetcher from '../../services/timeline_fetcher/timeline_fetcher.service.js' import Conversation from '../conversation/conversation.vue' +import TimelineMenu from '../timeline_menu/timeline_menu.vue' import { throttle, keyBy } from 'lodash' export const getExcludedStatusIdsByPinning = (statuses, pinnedStatusIds) => { @@ -35,6 +36,11 @@ const Timeline = { bottomedOut: false } }, + components: { + Status, + Conversation, + TimelineMenu + }, computed: { timelineError () { return this.$store.state.statuses.error @@ -45,11 +51,15 @@ const Timeline = { newStatusCount () { return this.timeline.newStatusCount }, - newStatusCountStr () { + showLoadButton () { + if (this.timelineError || this.errorData) return false + return this.timeline.newStatusCount > 0 || this.timeline.flushMarker !== 0 + }, + loadButtonString () { if (this.timeline.flushMarker !== 0) { - return '' + return this.$t('timeline.reload') } else { - return ` (${this.newStatusCount})` + return `${this.$t('timeline.show_new')} (${this.newStatusCount})` } }, classes () { @@ -70,10 +80,6 @@ const Timeline = { return keyBy(this.pinnedStatusIds) } }, - components: { - Status, - Conversation - }, created () { const store = this.$store const credentials = store.state.users.currentUser.credentials @@ -112,8 +118,6 @@ const Timeline = { if (e.key === '.') this.showNewStatuses() }, showNewStatuses () { - if (this.newStatusCount === 0) return - if (this.timeline.flushMarker !== 0) { this.$store.commit('clearTimeline', { timeline: this.timelineName, excludeUserId: true }) this.$store.commit('queueFlush', { timeline: this.timelineName, id: 0 }) @@ -135,7 +139,7 @@ const Timeline = { showImmediately: true, userId: this.userId, tag: this.tag - }).then(statuses => { + }).then(({ statuses }) => { store.commit('setLoading', { timeline: this.timelineName, value: false }) if (statuses && statuses.length === 0) { this.bottomedOut = true @@ -146,7 +150,6 @@ const Timeline = { const bodyBRect = document.body.getBoundingClientRect() const height = Math.max(bodyBRect.height, -(bodyBRect.y)) if (this.timeline.loading === false && - this.$store.getters.mergedConfig.autoLoad && this.$el.offsetHeight > 0 && (window.innerHeight + window.pageYOffset) >= (height - 750)) { this.fetchOlderStatuses() diff --git a/src/components/timeline/timeline.vue b/src/components/timeline/timeline.vue index 9777bd0c..2ff933e9 100644 --- a/src/components/timeline/timeline.vue +++ b/src/components/timeline/timeline.vue @@ -1,9 +1,7 @@ <template> - <div :class="classes.root"> + <div :class="[classes.root, 'timeline']"> <div :class="classes.header"> - <div class="title"> - {{ title }} - </div> + <TimelineMenu v-if="!embedded" /> <div v-if="timelineError" class="loadmore-error alert error" @@ -19,14 +17,14 @@ {{ errorData.statusText }} </div> <button - v-if="timeline.newStatusCount > 0 && !timelineError && !errorData" + v-else-if="showLoadButton" class="loadmore-button" @click.prevent="showNewStatuses" > - {{ $t('timeline.show_new') }}{{ newStatusCountStr }} + {{ loadButtonString }} </button> <div - v-if="!timeline.newStatusCount > 0 && !timelineError && !errorData" + v-else class="loadmore-text faint" @click.prevent > @@ -106,4 +104,16 @@ opacity: 1; } } + +.timeline-heading { + max-width: 100%; + flex-wrap: nowrap; + .loadmore-button { + flex-shrink: 0; + } + .loadmore-text { + flex-shrink: 0; + line-height: 1em; + } +} </style> diff --git a/src/components/timeline_menu/timeline_menu.js b/src/components/timeline_menu/timeline_menu.js new file mode 100644 index 00000000..2be75b06 --- /dev/null +++ b/src/components/timeline_menu/timeline_menu.js @@ -0,0 +1,63 @@ +import Popover from '../popover/popover.vue' +import { mapState } from 'vuex' + +// Route -> i18n key mapping, exported andnot in the computed +// because nav panel benefits from the same information. +export const timelineNames = () => { + return { + 'friends': 'nav.timeline', + 'bookmarks': 'nav.bookmarks', + 'dms': 'nav.dms', + 'public-timeline': 'nav.public_tl', + 'public-external-timeline': 'nav.twkn', + 'tag-timeline': 'tag' + } +} + +const TimelineMenu = { + components: { + Popover + }, + data () { + return { + isOpen: false + } + }, + created () { + if (this.currentUser && this.currentUser.locked) { + this.$store.dispatch('startFetchingFollowRequests') + } + if (timelineNames()[this.$route.name]) { + this.$store.dispatch('setLastTimeline', this.$route.name) + } + }, + methods: { + openMenu () { + // $nextTick is too fast, animation won't play back but + // instead starts in fully open position. Low values + // like 1-5 work on fast machines but not on mobile, 25 + // seems like a good compromise that plays without significant + // added lag. + setTimeout(() => { + this.isOpen = true + }, 25) + }, + timelineName () { + const route = this.$route.name + if (route === 'tag-timeline') { + return '#' + this.$route.params.tag + } + const i18nkey = timelineNames()[this.$route.name] + return i18nkey ? this.$t(i18nkey) : route + } + }, + computed: { + ...mapState({ + currentUser: state => state.users.currentUser, + privateMode: state => state.instance.private, + federating: state => state.instance.federating + }) + } +} + +export default TimelineMenu diff --git a/src/components/timeline_menu/timeline_menu.vue b/src/components/timeline_menu/timeline_menu.vue new file mode 100644 index 00000000..be512d60 --- /dev/null +++ b/src/components/timeline_menu/timeline_menu.vue @@ -0,0 +1,180 @@ +<template> + <Popover + trigger="click" + class="timeline-menu" + :class="{ 'open': isOpen }" + :margin="{ left: -15, right: -200 }" + :bound-to="{ x: 'container' }" + popover-class="timeline-menu-popover-wrap" + @show="openMenu" + @close="() => isOpen = false" + > + <div + slot="content" + class="timeline-menu-popover panel panel-default" + > + <ul> + <li v-if="currentUser"> + <router-link :to="{ name: 'friends' }"> + <i class="button-icon icon-home-2" />{{ $t("nav.timeline") }} + </router-link> + </li> + <li v-if="currentUser"> + <router-link :to="{ name: 'bookmarks'}"> + <i class="button-icon icon-bookmark" />{{ $t("nav.bookmarks") }} + </router-link> + </li> + <li v-if="currentUser"> + <router-link :to="{ name: 'dms', params: { username: currentUser.screen_name } }"> + <i class="button-icon icon-mail-alt" />{{ $t("nav.dms") }} + </router-link> + </li> + <li v-if="currentUser || !privateMode"> + <router-link :to="{ name: 'public-timeline' }"> + <i class="button-icon icon-users" />{{ $t("nav.public_tl") }} + </router-link> + </li> + <li v-if="federating && (currentUser || !privateMode)"> + <router-link :to="{ name: 'public-external-timeline' }"> + <i class="button-icon icon-globe" />{{ $t("nav.twkn") }} + </router-link> + </li> + </ul> + </div> + <div + slot="trigger" + class="title timeline-menu-title" + > + <span>{{ timelineName() }}</span> + <i class="icon-down-open" /> + </div> + </Popover> +</template> + +<script src="./timeline_menu.js" ></script> + +<style lang="scss"> +@import '../../_variables.scss'; + +.timeline-menu { + flex-shrink: 1; + margin-right: auto; + min-width: 0; + width: 24rem; + .timeline-menu-popover-wrap { + overflow: hidden; + // Match panel heading padding to line up menu with bottom of heading + margin-top: 0.6rem; + padding: 0 15px 15px 15px; + } + .timeline-menu-popover { + width: 24rem; + max-width: 100vw; + margin: 0; + font-size: 1rem; + border-top-right-radius: 0; + border-top-left-radius: 0; + transform: translateY(-100%); + transition: transform 100ms; + } + .panel::after { + border-top-right-radius: 0; + border-top-left-radius: 0; + } + &.open .timeline-menu-popover { + transform: translateY(0); + } + + .timeline-menu-title { + margin: 0; + cursor: pointer; + display: flex; + user-select: none; + width: 100%; + + span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + i { + margin-left: 0.6em; + flex-shrink: 0; + font-size: 1rem; + transition: transform 100ms; + } + } + + &.open .timeline-menu-title i { + color: $fallback--text; + color: var(--panelText, $fallback--text); + transform: rotate(180deg); + } + + .panel { + box-shadow: var(--popoverShadow); + } + + ul { + list-style: none; + margin: 0; + padding: 0; + } + + li { + border-bottom: 1px solid; + border-color: $fallback--border; + border-color: var(--border, $fallback--border); + padding: 0; + + &:last-child a { + border-bottom-right-radius: $fallback--panelRadius; + border-bottom-right-radius: var(--panelRadius, $fallback--panelRadius); + border-bottom-left-radius: $fallback--panelRadius; + border-bottom-left-radius: var(--panelRadius, $fallback--panelRadius); + } + + &:last-child { + border: none; + } + + i { + margin: 0 0.5em; + } + } + + a { + display: block; + padding: 0.6em 0; + + &:hover { + background-color: $fallback--lightBg; + background-color: var(--selectedMenu, $fallback--lightBg); + color: $fallback--link; + color: var(--selectedMenuText, $fallback--link); + --faint: var(--selectedMenuFaintText, $fallback--faint); + --faintLink: var(--selectedMenuFaintLink, $fallback--faint); + --lightText: var(--selectedMenuLightText, $fallback--lightText); + --icon: var(--selectedMenuIcon, $fallback--icon); + } + + &.router-link-active { + font-weight: bolder; + background-color: $fallback--lightBg; + background-color: var(--selectedMenu, $fallback--lightBg); + color: $fallback--text; + color: var(--selectedMenuText, $fallback--text); + --faint: var(--selectedMenuFaintText, $fallback--faint); + --faintLink: var(--selectedMenuFaintLink, $fallback--faint); + --lightText: var(--selectedMenuLightText, $fallback--lightText); + --icon: var(--selectedMenuIcon, $fallback--icon); + + &:hover { + text-decoration: underline; + } + } + } +} + +</style> 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..e4e4127c 100644 --- a/src/components/user_avatar/user_avatar.vue +++ b/src/components/user_avatar/user_avatar.vue @@ -1,9 +1,9 @@ <template> <StillImage - class="avatar" + 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" /> @@ -13,7 +13,9 @@ <style lang="scss"> @import '../../_variables.scss'; -.avatar.still-image { +.Avatar { + --still-image-label-visibility: hidden; + width: 48px; height: 48px; box-shadow: var(--avatarStatusShadow); diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue index 9529d7f6..041bb80f 100644 --- a/src/components/user_card/user_card.vue +++ b/src/components/user_card/user_card.vue @@ -66,6 +66,7 @@ <div class="bottom-line"> <router-link class="user-screen-name" + :title="user.screen_name" :to="userProfileLink(user)" > @{{ user.screen_name }} @@ -353,7 +354,7 @@ align-items: flex-start; max-height: 56px; - .avatar { + .Avatar { flex: 1 0 100%; width: 56px; height: 56px; @@ -363,13 +364,9 @@ } } - &:hover .animated.avatar { - canvas { - display: none; - } - img { - visibility: visible; - } + &:hover .Avatar { + --still-image-img: visible; + --still-image-canvas: hidden; } &-avatar-link { diff --git a/src/components/user_list_popover/user_list_popover.js b/src/components/user_list_popover/user_list_popover.js new file mode 100644 index 00000000..b60f2c4c --- /dev/null +++ b/src/components/user_list_popover/user_list_popover.js @@ -0,0 +1,18 @@ + +const UserListPopover = { + name: 'UserListPopover', + props: [ + 'users' + ], + components: { + Popover: () => import('../popover/popover.vue'), + UserAvatar: () => import('../user_avatar/user_avatar.vue') + }, + computed: { + usersCapped () { + return this.users.slice(0, 16) + } + } +} + +export default UserListPopover diff --git a/src/components/user_list_popover/user_list_popover.vue b/src/components/user_list_popover/user_list_popover.vue new file mode 100644 index 00000000..185c73ca --- /dev/null +++ b/src/components/user_list_popover/user_list_popover.vue @@ -0,0 +1,71 @@ +<template> + <Popover + trigger="hover" + placement="top" + :offset="{ y: 5 }" + > + <template slot="trigger"> + <slot /> + </template> + <div + slot="content" + class="user-list-popover" + > + <div v-if="users.length"> + <div + v-for="(user) in usersCapped" + :key="user.id" + class="user-list-row" + > + <UserAvatar + :user="user" + class="avatar-small" + :compact="true" + /> + <div class="user-list-names"> + <!-- eslint-disable vue/no-v-html --> + <span v-html="user.name_html" /> + <!-- eslint-enable vue/no-v-html --> + <span class="user-list-screen-name">{{ user.screen_name }}</span> + </div> + </div> + </div> + <div v-else> + <i class="icon-spin4 animate-spin" /> + </div> + </div> + </Popover> +</template> + +<script src="./user_list_popover.js" ></script> + +<style lang="scss"> +@import '../../_variables.scss'; + +.user-list-popover { + padding: 0.5em; + + .user-list-row { + padding: 0.25em; + display: flex; + flex-direction: row; + + .user-list-names { + display: flex; + flex-direction: column; + margin-left: 0.5em; + min-width: 5em; + + img { + width: 1em; + height: 1em; + } + } + + .user-list-screen-name { + font-size: 9px; + } + } +} + +</style> diff --git a/src/components/user_panel/user_panel.vue b/src/components/user_panel/user_panel.vue index 1db4f648..5685916a 100644 --- a/src/components/user_panel/user_panel.vue +++ b/src/components/user_panel/user_panel.vue @@ -10,9 +10,7 @@ :hide-bio="true" rounded="top" /> - <div class="panel-footer"> - <PostStatusForm /> - </div> + <PostStatusForm /> </div> <auth-form v-else diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue index 361a3b5c..c7c67c0a 100644 --- a/src/components/user_profile/user_profile.vue +++ b/src/components/user_profile/user_profile.vue @@ -20,13 +20,14 @@ :key="index" class="user-profile-field" > - <!-- eslint-disable vue/no-v-html --> <dt :title="user.fields_text[index].name" class="user-profile-field-name" @click.prevent="linkClicked" - v-html="field.name" - /> + > + {{ field.name }} + </dt> + <!-- eslint-disable vue/no-v-html --> <dd :title="user.fields_text[index].value" class="user-profile-field-value" diff --git a/src/components/user_reporting_modal/user_reporting_modal.vue b/src/components/user_reporting_modal/user_reporting_modal.vue index 6ee53461..2a8d8d48 100644 --- a/src/components/user_reporting_modal/user_reporting_modal.vue +++ b/src/components/user_reporting_modal/user_reporting_modal.vue @@ -146,7 +146,8 @@ display: flex; justify-content: space-between; - > .status-el { + /* TODO cleanup this */ + > .Status { flex: 1; } diff --git a/src/components/video_attachment/video_attachment.vue b/src/components/video_attachment/video_attachment.vue index 97ddf1cd..1ffed4e0 100644 --- a/src/components/video_attachment/video_attachment.vue +++ b/src/components/video_attachment/video_attachment.vue @@ -4,6 +4,8 @@ :src="attachment.url" :loop="loopVideo" :controls="controls" + :alt="attachment.description" + :title="attachment.description" playsinline @loadeddata="onVideoDataLoad" /> 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) } |
