diff options
| author | Shpuld Shpludson <shp@cock.li> | 2020-11-06 17:27:25 +0000 |
|---|---|---|
| committer | Shpuld Shpludson <shp@cock.li> | 2020-11-06 17:27:25 +0000 |
| commit | 23232e1c8f35d7ddc8adb7a6dbf813b2831c90ec (patch) | |
| tree | b0c8c409d3af0901cb47de648ccbea53da89c16d /src | |
| parent | b225c3578f3c89af5ed3a0be3f8f3a6bbcedcc7d (diff) | |
| parent | 60983ae42b584694de0211ca67ef72d492a293c9 (diff) | |
Merge branch 'develop' into 'master'
Merge develop into master for 2.2.0
See merge request pleroma/pleroma-fe!1278
Diffstat (limited to 'src')
163 files changed, 4412 insertions, 1277 deletions
@@ -1,7 +1,6 @@ import UserPanel from './components/user_panel/user_panel.vue' import NavPanel from './components/nav_panel/nav_panel.vue' import Notifications from './components/notifications/notifications.vue' -import SearchBar from './components/search_bar/search_bar.vue' import InstanceSpecificPanel from './components/instance_specific_panel/instance_specific_panel.vue' import FeaturesPanel from './components/features_panel/features_panel.vue' import WhoToFollowPanel from './components/who_to_follow_panel/who_to_follow_panel.vue' @@ -11,6 +10,7 @@ import MediaModal from './components/media_modal/media_modal.vue' import SideDrawer from './components/side_drawer/side_drawer.vue' import MobilePostStatusButton from './components/mobile_post_status_button/mobile_post_status_button.vue' import MobileNav from './components/mobile_nav/mobile_nav.vue' +import DesktopNav from './components/desktop_nav/desktop_nav.vue' import UserReportingModal from './components/user_reporting_modal/user_reporting_modal.vue' import PostStatusModal from './components/post_status_modal/post_status_modal.vue' import GlobalNoticeList from './components/global_notice_list/global_notice_list.vue' @@ -22,7 +22,6 @@ export default { UserPanel, NavPanel, Notifications, - SearchBar, InstanceSpecificPanel, FeaturesPanel, WhoToFollowPanel, @@ -31,21 +30,14 @@ export default { SideDrawer, MobilePostStatusButton, MobileNav, + DesktopNav, SettingsModal, UserReportingModal, PostStatusModal, GlobalNoticeList }, data: () => ({ - mobileActivePanel: 'timeline', - searchBarHidden: true, - supportsMask: window.CSS && window.CSS.supports && ( - window.CSS.supports('mask-size', 'contain') || - window.CSS.supports('-webkit-mask-size', 'contain') || - window.CSS.supports('-moz-mask-size', 'contain') || - window.CSS.supports('-ms-mask-size', 'contain') || - window.CSS.supports('-o-mask-size', 'contain') - ) + mobileActivePanel: 'timeline' }), created () { // Load the locale from the storage @@ -61,28 +53,6 @@ export default { background () { return this.currentUser.background_image || this.$store.state.instance.background }, - enableMask () { return this.supportsMask && this.$store.state.instance.logoMask }, - logoStyle () { - return { - 'visibility': this.enableMask ? 'hidden' : 'visible' - } - }, - logoMaskStyle () { - return this.enableMask ? { - 'mask-image': `url(${this.$store.state.instance.logo})` - } : { - 'background-color': this.enableMask ? '' : 'transparent' - } - }, - logoBgStyle () { - return Object.assign({ - 'margin': `${this.$store.state.instance.logoMargin} 0`, - opacity: this.searchBarHidden ? 1 : 0 - }, this.enableMask ? {} : { - 'background-color': this.enableMask ? '' : 'transparent' - }) - }, - logo () { return this.$store.state.instance.logo }, bgStyle () { return { 'background-image': `url(${this.background})` @@ -93,9 +63,7 @@ export default { '--body-background-image': `url(${this.background})` } }, - sitename () { return this.$store.state.instance.name }, chat () { return this.$store.state.chat.channel.state === 'joined' }, - hideSitename () { return this.$store.state.instance.hideSitename }, suggestionsEnabled () { return this.$store.state.instance.suggestionsEnabled }, showInstanceSpecificPanel () { return this.$store.state.instance.showInstanceSpecificPanel && @@ -112,19 +80,6 @@ export default { } }, methods: { - scrollToTop () { - window.scrollTo(0, 0) - }, - logout () { - this.$router.replace('/main/public') - this.$store.dispatch('logout') - }, - onSearchBarToggled (hidden) { - this.searchBarHidden = hidden - }, - openSettingsModal () { - this.$store.dispatch('openSettingsModal') - }, updateMobileState () { const mobileLayout = windowWidth() <= 800 const layoutHeight = windowHeight() diff --git a/src/App.scss b/src/App.scss index e2e2d079..ca7d33cd 100644 --- a/src/App.scss +++ b/src/App.scss @@ -85,7 +85,7 @@ button { font-family: sans-serif; font-family: var(--interfaceFont, sans-serif); - i[class*=icon-] { + i[class*=icon-], .svg-inline--fa { color: $fallback--text; color: var(--btnText, $fallback--text); } @@ -106,7 +106,8 @@ button { color: var(--btnPressedText, $fallback--text); background-color: $fallback--fg; background-color: var(--btnPressed, $fallback--fg); - i { + + svg, i { color: $fallback--text; color: var(--btnPressedText, $fallback--text); } @@ -118,7 +119,8 @@ button { color: var(--btnDisabledText, $fallback--text); background-color: $fallback--fg; background-color: var(--btnDisabled, $fallback--fg); - i { + + svg, i { color: $fallback--text; color: var(--btnDisabledText, $fallback--text); } @@ -131,7 +133,8 @@ button { background-color: var(--btnToggled, $fallback--fg); box-shadow: 0px 0px 4px 0px rgba(255, 255, 255, 0.3), 0px 1px 0px 0px rgba(0, 0, 0, 0.2) inset, 0px -1px 0px 0px rgba(255, 255, 255, 0.2) inset; box-shadow: var(--buttonPressedShadow); - i { + + svg, i { color: $fallback--text; color: var(--btnToggledText, $fallback--text); } @@ -185,7 +188,7 @@ input, textarea, .select, .input { opacity: 0.5; } - .icon-down-open { + .select-down-icon { position: absolute; top: 0; bottom: 0; @@ -279,7 +282,7 @@ input, textarea, .select, .input { + label::before { flex-shrink: 0; display: inline-block; - content: '✔'; + content: '✓'; transition: color 200ms; width: 1.1em; height: 1.1em; @@ -318,7 +321,7 @@ option { } } -i[class*=icon-] { +i[class*=icon-], .svg-inline--fa { color: $fallback--icon; color: var(--icon, $fallback--icon); } @@ -356,117 +359,10 @@ i[class*=icon-] { padding: 0 10px 0 10px; } -.item { - flex: 1; - line-height: 50px; - height: 50px; - overflow: hidden; - display: flex; - flex-wrap: wrap; - - .nav-icon { - margin-left: 0.4em; - } - - &.right { - justify-content: flex-end; - } -} - .auto-size { flex: 1 } -.nav-bar { - padding: 0; - width: 100%; - align-items: center; - position: fixed; - height: 50px; - box-sizing: border-box; - - button { - &, i[class*=icon-] { - color: $fallback--text; - color: var(--btnTopBarText, $fallback--text); - } - - &:active { - background-color: $fallback--fg; - background-color: var(--btnPressedTopBar, $fallback--fg); - color: $fallback--text; - color: var(--btnPressedTopBarText, $fallback--text); - } - - &:disabled { - color: $fallback--text; - color: var(--btnDisabledTopBarText, $fallback--text); - } - - &.toggled { - color: $fallback--text; - color: var(--btnToggledTopBarText, $fallback--text); - background-color: $fallback--fg; - background-color: var(--btnToggledTopBar, $fallback--fg) - } - } - - - .logo { - display: flex; - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - - align-items: stretch; - justify-content: center; - flex: 0 0 auto; - z-index: -1; - transition: opacity; - transition-timing-function: ease-out; - transition-duration: 100ms; - - .mask { - mask-repeat: no-repeat; - mask-position: center; - mask-size: contain; - background-color: $fallback--fg; - background-color: var(--topBarText, $fallback--fg); - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - } - - img { - height: 100%; - object-fit: contain; - display: block; - flex: 0; - } - } - - .inner-nav { - position: relative; - margin: auto; - box-sizing: border-box; - padding-left: 10px; - padding-right: 10px; - display: flex; - align-items: center; - flex-basis: 970px; - height: 50px; - - a, a i { - color: $fallback--link; - color: var(--topBarLink, $fallback--link); - } - } -} - main-router { flex: 1; } @@ -707,19 +603,24 @@ nav { flex-grow: 0; } } + .badge { + box-sizing: border-box; display: inline-block; border-radius: 99px; - min-width: 22px; - max-width: 22px; - min-height: 22px; - max-height: 22px; - font-size: 15px; - line-height: 22px; - text-align: center; + max-width: 10em; + min-width: 1.7em; + height: 1.3em; + padding: 0.15em 0.15em; vertical-align: middle; + font-weight: normal; + font-style: normal; + font-size: 0.9em; + line-height: 1; + text-align: center; white-space: nowrap; - padding: 0; + overflow: hidden; + text-overflow: ellipsis; &.badge-notification { background-color: $fallback--cRed; @@ -776,16 +677,6 @@ nav { } } -@media all and (min-width: 800px) { - .logo { - opacity: 1 !important; - } -} - -.item.right { - text-align: right; -} - .visibility-notice { padding: .5em; border: 1px solid $fallback--faint; @@ -807,8 +698,16 @@ nav { } } -.button-icon { - font-size: 1.2em; +.fa-scale-110 { + &.svg-inline--fa { + font-size: 1.1em; + } +} + +.fa-old-padding { + &.svg-inline--fa { + padding: 0 0.3em; + } } @keyframes shakeError { @@ -930,19 +829,6 @@ nav { background-color: var(--panel, $fallback--fg); } -.unread-chat-count { - font-size: 0.9em; - font-weight: bolder; - font-style: normal; - position: absolute; - right: 0.6rem; - padding: 0 0.3em; - min-width: 1.3rem; - min-height: 1.3rem; - max-height: 1.3rem; - line-height: 1.3rem; -} - .chat-layout { // Needed for smoother chat navigation in the desktop Safari (otherwise the chat layout "jumps" as the chat opens). overflow: hidden; diff --git a/src/App.vue b/src/App.vue index 0276c6a6..b4eb0524 100644 --- a/src/App.vue +++ b/src/App.vue @@ -9,74 +9,7 @@ :style="bgStyle" /> <MobileNav v-if="isMobileLayout" /> - <nav - v-else - id="nav" - class="nav-bar container" - @click="scrollToTop()" - > - <div class="inner-nav"> - <div - class="logo" - :style="logoBgStyle" - > - <div - class="mask" - :style="logoMaskStyle" - /> - <img - :src="logo" - :style="logoStyle" - > - </div> - <div class="item"> - <router-link - v-if="!hideSitename" - class="site-name" - :to="{ name: 'root' }" - active-class="home" - > - {{ sitename }} - </router-link> - </div> - <div class="item right"> - <search-bar - v-if="currentUser || !privateMode" - class="nav-icon mobile-hidden" - @toggled="onSearchBarToggled" - @click.stop.native - /> - <a - href="#" - class="mobile-hidden" - @click.stop="openSettingsModal" - > - <i - class="button-icon icon-cog nav-icon" - :title="$t('nav.preferences')" - /> - </a> - <a - v-if="currentUser && currentUser.role === 'admin'" - href="/pleroma/admin/#/login-pleroma" - class="mobile-hidden" - target="_blank" - ><i - class="button-icon icon-gauge nav-icon" - :title="$t('nav.administration')" - /></a> - <a - v-if="currentUser" - href="#" - class="mobile-hidden" - @click.prevent="logout" - ><i - class="button-icon icon-logout nav-icon" - :title="$t('login.logout')" - /></a> - </div> - </div> - </nav> + <DesktopNav v-else /> <div class="app-bg-wrapper app-container-wrapper" /> <div id="content" diff --git a/src/boot/after_store.js b/src/boot/after_store.js index 00ca74a2..3cbbf020 100644 --- a/src/boot/after_store.js +++ b/src/boot/after_store.js @@ -130,6 +130,7 @@ const setSettings = async ({ apiConfig, staticConfig, store }) => { ? 0 : config.logoMargin }) + copyInstanceOption('logoLeft') store.commit('authFlow/setInitialStrategy', config.loginMethod) copyInstanceOption('redirectRootNoLogin') diff --git a/src/components/account_actions/account_actions.js b/src/components/account_actions/account_actions.js index 6d345bc7..395d6685 100644 --- a/src/components/account_actions/account_actions.js +++ b/src/components/account_actions/account_actions.js @@ -1,6 +1,14 @@ import { mapState } from 'vuex' import ProgressButton from '../progress_button/progress_button.vue' import Popover from '../popover/popover.vue' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faEllipsisV +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faEllipsisV +) const AccountActions = { props: [ diff --git a/src/components/account_actions/account_actions.vue b/src/components/account_actions/account_actions.vue index 987e94b7..e3ae376e 100644 --- a/src/components/account_actions/account_actions.vue +++ b/src/components/account_actions/account_actions.vue @@ -1,5 +1,5 @@ <template> - <div class="account-actions"> + <div class="AccountActions"> <Popover trigger="click" placement="bottom" @@ -63,7 +63,10 @@ slot="trigger" class="btn btn-default ellipsis-button" > - <i class="icon-ellipsis trigger-button" /> + <FAIcon + class="icon" + icon="ellipsis-v" + /> </div> </Popover> </div> @@ -73,22 +76,22 @@ <style lang="scss"> @import '../../_variables.scss'; -.account-actions { - margin: 0 .8em; -} +.AccountActions { + button.dropdown-item { + margin-left: 0; + } -.account-actions button.dropdown-item { - margin-left: 0; -} + .ellipsis-button { + cursor: pointer; + width: 2.5em; + margin: -0.5em 0; + padding: 0.5em 0; + text-align: center; -.account-actions .trigger-button { - color: $fallback--lightText; - color: var(--lightText, $fallback--lightText); - opacity: .8; - cursor: pointer; - &:hover { - color: $fallback--text; - color: var(--text, $fallback--text); + &:not(:hover) .icon { + color: $fallback--lightText; + color: var(--lightText, $fallback--lightText); + } } } </style> diff --git a/src/components/attachment/attachment.js b/src/components/attachment/attachment.js index cb31020d..e23fcb1b 100644 --- a/src/components/attachment/attachment.js +++ b/src/components/attachment/attachment.js @@ -3,6 +3,20 @@ import VideoAttachment from '../video_attachment/video_attachment.vue' import nsfwImage from '../../assets/nsfw.png' import fileTypeService from '../../services/file_type/file_type.service.js' import { mapGetters } from 'vuex' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faFile, + faMusic, + faImage, + faVideo +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faFile, + faMusic, + faImage, + faVideo +) const Attachment = { props: [ @@ -39,10 +53,10 @@ const Attachment = { 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' + if (this.type === 'image') return 'image' + if (this.type === 'video') return 'video' + if (this.type === 'audio') return 'music' + return 'file' }, referrerpolicy () { return this.$store.state.instance.mediaProxyAvailable ? '' : 'no-referrer' diff --git a/src/components/attachment/attachment.vue b/src/components/attachment/attachment.vue index 63e0ceba..f1fac2c8 100644 --- a/src/components/attachment/attachment.vue +++ b/src/components/attachment/attachment.vue @@ -12,7 +12,7 @@ :alt="attachment.description" :title="attachment.description" > - <span :class="placeholderIconClass" /> + <FAIcon :icon="placeholderIconClass" /> <b>{{ nsfw ? "NSFW / " : "" }}</b>{{ placeholderName }} </a> </div> @@ -28,7 +28,7 @@ :href="attachment.url" :alt="attachment.description" :title="attachment.description" - @click.prevent="toggleHidden" + @click.prevent.stop="toggleHidden" > <img :key="nsfwImage" @@ -36,9 +36,10 @@ :src="nsfwImage" :class="{'small': isSmall}" > - <i + <FAIcon v-if="type === 'video'" - class="play-icon icon-play-circled" + class="play-icon" + icon="play-circle" /> </a> <div @@ -80,10 +81,13 @@ class="video" :attachment="attachment" :controls="allowPlay" + @play="$emit('play')" + @pause="$emit('pause')" /> - <i + <FAIcon v-if="!allowPlay" - class="play-icon icon-play-circled" + class="play-icon" + icon="play-circle" /> </a> @@ -93,6 +97,8 @@ :alt="attachment.description" :title="attachment.description" controls + @play="$emit('play')" + @pause="$emit('pause')" /> <div @@ -138,6 +144,10 @@ white-space: nowrap; text-overflow: ellipsis; max-width: 100%; + + svg { + color: inherit; + } } .nsfw-placeholder { diff --git a/src/components/chat/chat.js b/src/components/chat/chat.js index 803abf69..e57fcb91 100644 --- a/src/components/chat/chat.js +++ b/src/components/chat/chat.js @@ -5,11 +5,25 @@ 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' +import { promiseInterval } from '../../services/promise_interval/promise_interval.js' +import { getScrollPosition, getNewTopPosition, isBottomedOut, scrollableContainerHeight, isScrollable } from './chat_layout_utils.js' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faChevronDown, + faChevronLeft +} from '@fortawesome/free-solid-svg-icons' +import { buildFakeMessage } from '../../services/chat_utils/chat_utils.js' + +library.add( + faChevronDown, + faChevronLeft +) const BOTTOMED_OUT_OFFSET = 10 const JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET = 150 const SAFE_RESIZE_TIME_OFFSET = 100 +const MARK_AS_READ_DELAY = 1500 +const MAX_RETRIES = 10 const Chat = { components: { @@ -23,7 +37,8 @@ const Chat = { hoveredMessageChainId: undefined, lastScrollPosition: {}, scrollableContainerHeight: '100%', - errorLoadingChat: false + errorLoadingChat: false, + messageRetriers: {} } }, created () { @@ -93,7 +108,7 @@ const Chat = { const bottomedOutBeforeUpdate = this.bottomedOut(BOTTOMED_OUT_OFFSET) this.$nextTick(() => { if (bottomedOutBeforeUpdate) { - this.scrollDown({ forceRead: !document.hidden }) + this.scrollDown() } }) }, @@ -199,7 +214,7 @@ const Chat = { this.$nextTick(() => { scrollable.scrollTo({ top: scrollable.scrollHeight, left: 0, behavior }) }) - if (forceRead || this.newMessageCount > 0) { + if (forceRead) { this.readChat() } }, @@ -207,7 +222,10 @@ const Chat = { if (!(this.currentChatMessageService && this.currentChatMessageService.maxId)) { return } if (document.hidden) { return } const lastReadId = this.currentChatMessageService.maxId - this.$store.dispatch('readChat', { id: this.currentChat.id, lastReadId }) + this.$store.dispatch('readChat', { + id: this.currentChat.id, + lastReadId + }) }, bottomedOut (offset) { return isBottomedOut(this.$refs.scrollable, offset) @@ -224,12 +242,18 @@ const Chat = { } else if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) { this.jumpToBottomButtonVisible = false if (this.newMessageCount > 0) { - this.readChat() + // Use a delay before marking as read to prevent situation where new messages + // arrive just as you're leaving the view and messages that you didn't actually + // get to see get marked as read. + window.setTimeout(() => { + // Don't mark as read if the element doesn't exist, user has left chat view + if (this.$el) this.readChat() + }, MARK_AS_READ_DELAY) } } else { this.jumpToBottomButtonVisible = true } - }, 100), + }, 200), handleScrollUp (positionBeforeLoading) { const positionAfterLoading = getScrollPosition(this.$refs.scrollable) this.$refs.scrollable.scrollTo({ @@ -246,7 +270,7 @@ const Chat = { const fetchOlderMessages = !!maxId const sinceId = fetchLatest && chatMessageService.maxId - this.backendInteractor.chatMessages({ id: chatId, maxId, sinceId }) + return 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) { @@ -263,6 +287,14 @@ const Chat = { if (isFirstFetch) { this.updateScrollableContainerHeight() } + + // In vertical screens, the first batch of fetched messages may not always take the + // full height of the scrollable container. + // If this is the case, we want to fetch the messages until the scrollable container + // is fully populated so that the user has the ability to scroll up and load the history. + if (!isScrollable(this.$refs.scrollable) && messages.length > 0) { + this.fetchChat({ maxId: this.currentChatMessageService.minId }) + } }) }) }) @@ -287,46 +319,78 @@ const Chat = { }, doStartFetching () { this.$store.dispatch('startFetchingCurrentChat', { - fetcher: () => setInterval(() => this.fetchChat({ fetchLatest: true }), 5000) + fetcher: () => promiseInterval(() => this.fetchChat({ fetchLatest: true }), 5000) }) this.fetchChat({ isFirstFetch: true }) }, - sendMessage ({ status, media }) { + handleAttachmentPosting () { + 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 }) + }) + }, + sendMessage ({ status, media, idempotencyKey }) { const params = { id: this.currentChat.id, - content: status + content: status, + idempotencyKey } if (media[0]) { params.mediaId = media[0].id } - return this.backendInteractor.sendChatMessage(params) + const fakeMessage = buildFakeMessage({ + attachments: media, + chatId: this.currentChat.id, + content: status, + userId: this.currentUser.id, + idempotencyKey + }) + + this.$store.dispatch('addChatMessages', { + chatId: this.currentChat.id, + messages: [fakeMessage] + }).then(() => { + this.handleAttachmentPosting() + }) + + return this.doSendMessage({ params, fakeMessage, retriesLeft: MAX_RETRIES }) + }, + doSendMessage ({ params, fakeMessage, retriesLeft = MAX_RETRIES }) { + if (retriesLeft <= 0) return + + this.backendInteractor.sendChatMessage(params) .then(data => { this.$store.dispatch('addChatMessages', { chatId: this.currentChat.id, - messages: [data], - updateMaxId: false - }).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 }) - }) + updateMaxId: false, + messages: [{ ...data, fakeId: fakeMessage.id }] }) return data }) .catch(error => { console.error('Error sending message', error) - return { - error: this.$t('chats.error_sending_message') + this.$store.dispatch('handleMessageError', { + chatId: this.currentChat.id, + fakeId: fakeMessage.id, + isRetry: retriesLeft !== MAX_RETRIES + }) + if ((error.statusCode >= 500 && error.statusCode < 600) || error.message === 'Failed to fetch') { + this.messageRetriers[fakeMessage.id] = setTimeout(() => { + this.doSendMessage({ params, fakeMessage, retriesLeft: retriesLeft - 1 }) + }, 1000 * (2 ** (MAX_RETRIES - retriesLeft))) } + return {} }) + + return Promise.resolve(fakeMessage) }, goBack () { this.$router.push({ name: 'chats', params: { username: this.currentUser.screen_name } }) diff --git a/src/components/chat/chat.scss b/src/components/chat/chat.scss index 012a1b1d..aef58495 100644 --- a/src/components/chat/chat.scss +++ b/src/components/chat/chat.scss @@ -25,7 +25,7 @@ min-height: 100%; margin: 0 0 0 0; border-radius: 10px 10px 0 0; - border-radius: var(--panelRadius, 10px) var(--panelRadius, 10px) 0 0 ; + border-radius: var(--panelRadius, 10px) var(--panelRadius, 10px) 0 0; &::after { border-radius: 0; @@ -58,12 +58,10 @@ .go-back-button { cursor: pointer; - margin-right: 1.4em; - - i { - display: flex; - align-items: center; - } + width: 28px; + text-align: center; + padding: 0.6em; + margin: -0.6em 0.6em -0.6em -0.6em; } .jump-to-bottom-button { @@ -78,7 +76,7 @@ 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); + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.3), 0 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); @@ -140,11 +138,21 @@ } .chat-view-heading { + box-sizing: border-box; position: static; z-index: 9999; top: 0; margin-top: 0; border-radius: 0; + + /* This practically overlays the panel heading color over panel background + * color. This is needed because we allow transparent panel background and + * it doesn't work well in this "disjointed panel header" case + */ + background: + linear-gradient(to top, var(--panel), var(--panel)), + linear-gradient(to top, var(--bg), var(--bg)); + height: 50px; } .scrollable-message-list { diff --git a/src/components/chat/chat.vue b/src/components/chat/chat.vue index 2e4538c8..94a0097c 100644 --- a/src/components/chat/chat.vue +++ b/src/components/chat/chat.vue @@ -14,7 +14,10 @@ class="go-back-button" @click="goBack" > - <i class="button-icon icon-left-open" /> + <FAIcon + size="lg" + icon="chevron-left" + /> </a> <div class="title text-center"> <ChatTitle @@ -58,14 +61,15 @@ :class="{ 'visible': jumpToBottomButtonVisible }" @click="scrollDown({ behavior: 'smooth' })" > - <i class="icon-down-open"> + <span> + <FAIcon icon="chevron-down" /> <div v-if="newMessageCount" class="badge badge-notification unread-chat-count unread-message-count" > {{ newMessageCount }} </div> - </i> + </span> </div> <PostStatusForm :disable-subject="true" @@ -76,6 +80,7 @@ :disable-sensitivity-checkbox="true" :disable-submit="errorLoadingChat || !currentChat" :disable-preview="true" + :optimistic-posting="true" :post-handler="sendMessage" :submit-on-enter="!mobileLayout" :preserve-focus="!mobileLayout" diff --git a/src/components/chat/chat_layout_utils.js b/src/components/chat/chat_layout_utils.js index 609dc0c9..50a933ac 100644 --- a/src/components/chat/chat_layout_utils.js +++ b/src/components/chat/chat_layout_utils.js @@ -24,3 +24,10 @@ export const isBottomedOut = (el, offset = 0) => { export const scrollableContainerHeight = (inner, header, footer) => { return inner.offsetHeight - header.clientHeight - footer.clientHeight } + +// Returns whether or not the scrollbar is visible. +export const isScrollable = (el) => { + if (!el) return + + return el.scrollHeight > el.clientHeight +} diff --git a/src/components/chat_list_item/chat_list_item.vue b/src/components/chat_list_item/chat_list_item.vue index 1f8ecdf6..cd3f436e 100644 --- a/src/components/chat_list_item/chat_list_item.vue +++ b/src/components/chat_list_item/chat_list_item.vue @@ -21,6 +21,12 @@ /> </span> <span class="heading-right" /> + <div class="time-wrapper"> + <Timeago + :time="chat.updated_at" + :auto-update="60" + /> + </div> </div> <div class="chat-preview"> <StatusContent @@ -35,12 +41,6 @@ </div> </div> </div> - <div class="time-wrapper"> - <Timeago - :time="chat.updated_at" - :auto-update="60" - /> - </div> </div> </template> diff --git a/src/components/chat_message/chat_message.js b/src/components/chat_message/chat_message.js index be4a7c89..bb380f87 100644 --- a/src/components/chat_message/chat_message.js +++ b/src/components/chat_message/chat_message.js @@ -7,6 +7,16 @@ 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' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faTimes, + faEllipsisH +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faTimes, + faEllipsisH +) const ChatMessage = { name: 'ChatMessage', diff --git a/src/components/chat_message/chat_message.scss b/src/components/chat_message/chat_message.scss index 7d4ff60c..5af744a3 100644 --- a/src/components/chat_message/chat_message.scss +++ b/src/components/chat_message/chat_message.scss @@ -24,7 +24,7 @@ } } - .icon-ellipsis { + .menu-icon { cursor: pointer; &:hover, .extra-button-popover.open & { @@ -101,6 +101,19 @@ } } + .pending { + .status-content.media-body, .created-at { + color: var(--faint); + } + } + + .error { + .status-content.media-body, .created-at { + color: $fallback--cRed; + color: var(--badgeNotification, $fallback--cRed); + } + } + .incoming { a { color: var(--chatMessageIncomingLink, $fallback--link); diff --git a/src/components/chat_message/chat_message.vue b/src/components/chat_message/chat_message.vue index e923d694..3849ab6e 100644 --- a/src/components/chat_message/chat_message.vue +++ b/src/components/chat_message/chat_message.vue @@ -32,7 +32,7 @@ > <div class="media status" - :class="{ 'without-attachment': !hasAttachment }" + :class="{ 'without-attachment': !hasAttachment, 'pending': chatViewItem.data.pending, 'error': chatViewItem.data.error }" style="position: relative" @mouseenter="hovered = true" @mouseleave="hovered = false" @@ -56,15 +56,16 @@ class="dropdown-item dropdown-item-icon" @click="deleteMessage" > - <i class="icon-cancel" /> {{ $t("chats.delete") }} + <FAIcon icon="times" /> {{ $t("chats.delete") }} </button> </div> </div> <button slot="trigger" + class="menu-icon" :title="$t('chats.more')" > - <i class="icon-ellipsis" /> + <FAIcon icon="ellipsis-h" /> </button> </Popover> </div> diff --git a/src/components/chat_new/chat_new.js b/src/components/chat_new/chat_new.js index d023efc0..71585995 100644 --- a/src/components/chat_new/chat_new.js +++ b/src/components/chat_new/chat_new.js @@ -1,6 +1,16 @@ import { mapState, mapGetters } from 'vuex' import BasicUserCard from '../basic_user_card/basic_user_card.vue' import UserAvatar from '../user_avatar/user_avatar.vue' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faSearch, + faChevronLeft +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faSearch, + faChevronLeft +) const chatNew = { components: { diff --git a/src/components/chat_new/chat_new.scss b/src/components/chat_new/chat_new.scss index 11305444..5506143d 100644 --- a/src/components/chat_new/chat_new.scss +++ b/src/components/chat_new/chat_new.scss @@ -8,9 +8,7 @@ } } - .icon-search { - font-size: 1.5em; - float: right; + .search-icon { margin-right: 0.3em; } @@ -25,5 +23,9 @@ .go-back-button { cursor: pointer; + width: 28px; + text-align: center; + padding: 0.6em; + margin: -0.6em 0.6em -0.6em -0.6em; } } diff --git a/src/components/chat_new/chat_new.vue b/src/components/chat_new/chat_new.vue index 3333dbf9..f3894a3a 100644 --- a/src/components/chat_new/chat_new.vue +++ b/src/components/chat_new/chat_new.vue @@ -11,12 +11,18 @@ class="go-back-button" @click="goBack" > - <i class="button-icon icon-left-open" /> + <FAIcon + size="lg" + icon="chevron-left" + /> </a> </div> <div class="input-wrap"> <div class="input-search"> - <i class="button-icon icon-search" /> + <FAIcon + class="search-icon fa-scale-110 fa-old-padding" + icon="search" + /> </div> <input ref="search" diff --git a/src/components/chat_panel/chat_panel.js b/src/components/chat_panel/chat_panel.js index f2e3adf0..c3887098 100644 --- a/src/components/chat_panel/chat_panel.js +++ b/src/components/chat_panel/chat_panel.js @@ -1,4 +1,14 @@ import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faBullhorn, + faTimes +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faBullhorn, + faTimes +) const chatPanel = { props: [ 'floating' ], diff --git a/src/components/chat_panel/chat_panel.vue b/src/components/chat_panel/chat_panel.vue index 570435e7..7993c94d 100644 --- a/src/components/chat_panel/chat_panel.vue +++ b/src/components/chat_panel/chat_panel.vue @@ -11,9 +11,9 @@ > <div class="title"> <span>{{ $t('shoutbox.title') }}</span> - <i + <FAIcon v-if="floating" - class="icon-cancel" + icon="times" /> </div> </div> @@ -63,7 +63,10 @@ @click.stop.prevent="togglePanel" > <div class="title"> - <i class="icon-megaphone" /> + <FAIcon + class="icon" + icon="bullhorn" + /> {{ $t('shoutbox.title') }} </div> </div> @@ -87,7 +90,8 @@ .chat-panel { .chat-heading { cursor: pointer; - .icon-comment-empty { + + .icon { color: $fallback--text; color: var(--text, $fallback--text); } diff --git a/src/components/contrast_ratio/contrast_ratio.vue b/src/components/contrast_ratio/contrast_ratio.vue index 9dc871b6..374cb9ba 100644 --- a/src/components/contrast_ratio/contrast_ratio.vue +++ b/src/components/contrast_ratio/contrast_ratio.vue @@ -8,13 +8,13 @@ class="rating" > <span v-if="contrast.aaa"> - <i class="icon-thumbs-up-alt" /> + <FAIcon icon="thumbs-up" /> </span> <span v-if="!contrast.aaa && contrast.aa"> - <i class="icon-adjust" /> + <FAIcon icon="adjust" /> </span> <span v-if="!contrast.aaa && !contrast.aa"> - <i class="icon-attention" /> + <FAIcon icon="exclamation-triangle" /> </span> </span> <span @@ -23,19 +23,32 @@ :title="hint_18pt" > <span v-if="contrast.laaa"> - <i class="icon-thumbs-up-alt" /> + <FAIcon icon="thumbs-up" /> </span> <span v-if="!contrast.laaa && contrast.laa"> - <i class="icon-adjust" /> + <FAIcon icon="adjust" /> </span> <span v-if="!contrast.laaa && !contrast.laa"> - <i class="icon-attention" /> + <FAIcon icon="exclamation-triangle" /> </span> </span> </span> </template> <script> +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faAdjust, + faExclamationTriangle, + faThumbsUp +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faAdjust, + faExclamationTriangle, + faThumbsUp +) + export default { props: { large: { @@ -85,6 +98,7 @@ export default { .rating { display: inline-block; text-align: center; + margin-left: 0.5em; } } </style> diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js index 45fb2bf6..069c0b40 100644 --- a/src/components/conversation/conversation.js +++ b/src/components/conversation/conversation.js @@ -44,7 +44,8 @@ const conversation = { 'isPage', 'pinnedStatusIdsObject', 'inProfile', - 'profileUserId' + 'profileUserId', + 'virtualHidden' ], created () { if (this.isPage) { @@ -52,6 +53,13 @@ const conversation = { } }, computed: { + hideStatus () { + if (this.$refs.statusComponent && this.$refs.statusComponent[0]) { + return this.virtualHidden && this.$refs.statusComponent[0].suspendable + } else { + return this.virtualHidden + } + }, status () { return this.$store.state.statuses.allStatusesObject[this.statusId] }, @@ -102,6 +110,10 @@ const conversation = { }, isExpanded () { return this.expanded || this.isPage + }, + hiddenStyle () { + const height = (this.status && this.status.virtualHeight) || '120px' + return this.virtualHidden ? { height } : {} } }, components: { @@ -121,6 +133,12 @@ const conversation = { if (value) { this.fetchConversation() } + }, + virtualHidden (value) { + this.$store.dispatch( + 'setVirtualHeight', + { statusId: this.statusId, height: `${this.$el.clientHeight}px` } + ) } }, methods: { diff --git a/src/components/conversation/conversation.vue b/src/components/conversation/conversation.vue index 997a4d10..e0b9fcc5 100644 --- a/src/components/conversation/conversation.vue +++ b/src/components/conversation/conversation.vue @@ -1,5 +1,7 @@ <template> <div + v-if="!hideStatus" + :style="hiddenStyle" class="Conversation" :class="{ '-expanded' : isExpanded, 'panel' : isExpanded }" > @@ -18,6 +20,7 @@ <status v-for="status in conversation" :key="status.id" + ref="statusComponent" :inline-expanded="collapsable && isExpanded" :statusoid="status" :expandable="!isExpanded" @@ -33,6 +36,10 @@ @toggleExpanded="toggleExpanded" /> </div> + <div + v-else + :style="hiddenStyle" + /> </template> <script src="./conversation.js"></script> @@ -53,8 +60,8 @@ .conversation-status { border-color: $fallback--border; border-color: var(--border, $fallback--border); - border-left: 4px solid $fallback--cRed; - border-left: 4px solid var(--cRed, $fallback--cRed); + border-left-color: $fallback--cRed; + border-left-color: var(--cRed, $fallback--cRed); } .conversation-status:last-child { diff --git a/src/components/desktop_nav/desktop_nav.js b/src/components/desktop_nav/desktop_nav.js new file mode 100644 index 00000000..e048f53d --- /dev/null +++ b/src/components/desktop_nav/desktop_nav.js @@ -0,0 +1,89 @@ +import SearchBar from 'components/search_bar/search_bar.vue' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faSignInAlt, + faSignOutAlt, + faHome, + faComments, + faBell, + faUserPlus, + faBullhorn, + faSearch, + faTachometerAlt, + faCog, + faInfoCircle +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faSignInAlt, + faSignOutAlt, + faHome, + faComments, + faBell, + faUserPlus, + faBullhorn, + faSearch, + faTachometerAlt, + faCog, + faInfoCircle +) + +export default { + components: { + SearchBar + }, + data: () => ({ + searchBarHidden: true, + supportsMask: window.CSS && window.CSS.supports && ( + window.CSS.supports('mask-size', 'contain') || + window.CSS.supports('-webkit-mask-size', 'contain') || + window.CSS.supports('-moz-mask-size', 'contain') || + window.CSS.supports('-ms-mask-size', 'contain') || + window.CSS.supports('-o-mask-size', 'contain') + ) + }), + computed: { + enableMask () { return this.supportsMask && this.$store.state.instance.logoMask }, + logoStyle () { + return { + 'visibility': this.enableMask ? 'hidden' : 'visible' + } + }, + logoMaskStyle () { + return this.enableMask ? { + 'mask-image': `url(${this.$store.state.instance.logo})` + } : { + 'background-color': this.enableMask ? '' : 'transparent' + } + }, + logoBgStyle () { + return Object.assign({ + 'margin': `${this.$store.state.instance.logoMargin} 0`, + opacity: this.searchBarHidden ? 1 : 0 + }, this.enableMask ? {} : { + 'background-color': this.enableMask ? '' : 'transparent' + }) + }, + logo () { return this.$store.state.instance.logo }, + sitename () { return this.$store.state.instance.name }, + hideSitename () { return this.$store.state.instance.hideSitename }, + logoLeft () { return this.$store.state.instance.logoLeft }, + currentUser () { return this.$store.state.users.currentUser }, + privateMode () { return this.$store.state.instance.private } + }, + methods: { + scrollToTop () { + window.scrollTo(0, 0) + }, + logout () { + this.$router.replace('/main/public') + this.$store.dispatch('logout') + }, + onSearchBarToggled (hidden) { + this.searchBarHidden = hidden + }, + openSettingsModal () { + this.$store.dispatch('openSettingsModal') + } + } +} diff --git a/src/components/desktop_nav/desktop_nav.scss b/src/components/desktop_nav/desktop_nav.scss new file mode 100644 index 00000000..028692a9 --- /dev/null +++ b/src/components/desktop_nav/desktop_nav.scss @@ -0,0 +1,112 @@ +@import '../../_variables.scss'; + +.DesktopNav { + height: 50px; + width: 100%; + position: fixed; + + .inner-nav { + display: grid; + grid-template-rows: 50px; + grid-template-columns: 2fr auto 2fr; + grid-template-areas: "sitename logo actions"; + box-sizing: border-box; + padding: 0 1.2em; + margin: auto; + max-width: 980px; + } + + &.-logoLeft { + grid-template-columns: auto 2fr 2fr; + grid-template-areas: "logo sitename actions"; + } + + button { + &, svg { + color: $fallback--text; + color: var(--btnTopBarText, $fallback--text); + } + + &:active { + background-color: $fallback--fg; + background-color: var(--btnPressedTopBar, $fallback--fg); + color: $fallback--text; + color: var(--btnPressedTopBarText, $fallback--text); + } + + &:disabled { + color: $fallback--text; + color: var(--btnDisabledTopBarText, $fallback--text); + } + + &.toggled { + color: $fallback--text; + color: var(--btnToggledTopBarText, $fallback--text); + background-color: $fallback--fg; + background-color: var(--btnToggledTopBar, $fallback--fg) + } + } + + .logo { + grid-area: logo; + position: relative; + transition: opacity; + transition-timing-function: ease-out; + transition-duration: 100ms; + + @media all and (min-width: 800px) { + opacity: 1 !important; + } + + .mask { + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + background-color: $fallback--fg; + background-color: var(--topBarText, $fallback--fg); + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + } + + img { + display: inline-block; + height: 50px; + } + } + + .nav-icon { + margin-left: 0.2em; + width: 2em; + text-align: center; + } + + a, a svg { + color: $fallback--link; + color: var(--topBarLink, $fallback--link); + } + + .sitename { + grid-area: sitename; + } + + .actions { + grid-area: actions; + } + + .item { + flex: 1; + line-height: 50px; + height: 50px; + overflow: hidden; + display: flex; + flex-wrap: wrap; + + &.right { + justify-content: flex-end; + text-align: right; + } + } +} diff --git a/src/components/desktop_nav/desktop_nav.vue b/src/components/desktop_nav/desktop_nav.vue new file mode 100644 index 00000000..3a6e4033 --- /dev/null +++ b/src/components/desktop_nav/desktop_nav.vue @@ -0,0 +1,79 @@ +<template> + <nav + id="nav" + class="DesktopNav" + :class="{ '-logoLeft': logoLeft }" + @click="scrollToTop()" + > + <div class="inner-nav"> + <div class="item sitename"> + <router-link + v-if="!hideSitename" + class="site-name" + :to="{ name: 'root' }" + active-class="home" + > + {{ sitename }} + </router-link> + </div> + <router-link + class="logo" + :to="{ name: 'root' }" + :style="logoBgStyle" + > + <div + class="mask" + :style="logoMaskStyle" + /> + <img + :src="logo" + :style="logoStyle" + > + </router-link> + <div class="item right actions"> + <search-bar + v-if="currentUser || !privateMode" + @toggled="onSearchBarToggled" + @click.stop.native + /> + <a + href="#" + class="nav-icon" + @click.stop="openSettingsModal" + > + <FAIcon + fixed-width + class="fa-scale-110 fa-old-padding" + icon="cog" + :title="$t('nav.preferences')" + /> + </a> + <a + v-if="currentUser && currentUser.role === 'admin'" + href="/pleroma/admin/#/login-pleroma" + class="nav-icon" + target="_blank" + ><FAIcon + fixed-width + class="fa-scale-110 fa-old-padding" + icon="tachometer-alt" + :title="$t('nav.administration')" + /></a> + <a + v-if="currentUser" + href="#" + class="nav-icon" + @click.prevent="logout" + ><FAIcon + fixed-width + class="fa-scale-110 fa-old-padding" + icon="sign-out-alt" + :title="$t('login.logout')" + /></a> + </div> + </div> + </nav> +</template> +<script src="./desktop_nav.js"></script> + +<style src="./desktop_nav.scss" lang="scss"></style> diff --git a/src/components/emoji_input/emoji_input.js b/src/components/emoji_input/emoji_input.js index f0123447..87303d08 100644 --- a/src/components/emoji_input/emoji_input.js +++ b/src/components/emoji_input/emoji_input.js @@ -3,6 +3,15 @@ import EmojiPicker from '../emoji_picker/emoji_picker.vue' import { take } from 'lodash' import { findOffset } from '../../services/offset_finder/offset_finder.service.js' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faSmileBeam +} from '@fortawesome/free-regular-svg-icons' + +library.add( + faSmileBeam +) + /** * EmojiInput - augmented inputs for emoji and autocomplete support in inputs * without having to give up the comfort of <input/> and <textarea/> elements diff --git a/src/components/emoji_input/emoji_input.vue b/src/components/emoji_input/emoji_input.vue index b9a74572..224e72cf 100644 --- a/src/components/emoji_input/emoji_input.vue +++ b/src/components/emoji_input/emoji_input.vue @@ -11,7 +11,7 @@ class="emoji-picker-icon" @click.prevent="togglePicker" > - <i class="icon-smile" /> + <FAIcon :icon="['far', 'smile-beam']" /> </div> <EmojiPicker v-if="enableEmojiPicker" diff --git a/src/components/emoji_picker/emoji_picker.js b/src/components/emoji_picker/emoji_picker.js index 0f397b59..2716d93f 100644 --- a/src/components/emoji_picker/emoji_picker.js +++ b/src/components/emoji_picker/emoji_picker.js @@ -1,4 +1,16 @@ import Checkbox from '../checkbox/checkbox.vue' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faBoxOpen, + faStickyNote, + faSmileBeam +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faBoxOpen, + faStickyNote, + faSmileBeam +) // At widest, approximately 20 emoji are visible in a row, // loading 3 rows, could be overkill for narrow picker @@ -8,7 +20,20 @@ const LOAD_EMOJI_BY = 60 const LOAD_EMOJI_MARGIN = 64 const filterByKeyword = (list, keyword = '') => { - return list.filter(x => x.displayText.includes(keyword)) + if (keyword === '') return list + + const keywordLowercase = keyword.toLowerCase() + let orderedEmojiList = [] + for (const emoji of list) { + const indexOfKeyword = emoji.displayText.toLowerCase().indexOf(keywordLowercase) + if (indexOfKeyword > -1) { + if (!Array.isArray(orderedEmojiList[indexOfKeyword])) { + orderedEmojiList[indexOfKeyword] = [] + } + orderedEmojiList[indexOfKeyword].push(emoji) + } + } + return orderedEmojiList.flat() } const EmojiPicker = { @@ -164,13 +189,13 @@ const EmojiPicker = { { id: 'custom', text: this.$t('emoji.custom'), - icon: 'icon-smile', + icon: 'smile-beam', emojis: customEmojis }, { id: 'standard', text: this.$t('emoji.unicode'), - icon: 'icon-picture', + icon: 'box-open', emojis: filterByKeyword(standardEmojis, this.keyword) } ] diff --git a/src/components/emoji_picker/emoji_picker.scss b/src/components/emoji_picker/emoji_picker.scss index 8bd07e45..ec711758 100644 --- a/src/components/emoji_picker/emoji_picker.scss +++ b/src/components/emoji_picker/emoji_picker.scss @@ -82,7 +82,7 @@ &.active { border-bottom: 4px solid; - i { + svg { color: $fallback--lightText; color: var(--lightText, $fallback--lightText); } diff --git a/src/components/emoji_picker/emoji_picker.vue b/src/components/emoji_picker/emoji_picker.vue index 191b9fa1..3262a3d9 100644 --- a/src/components/emoji_picker/emoji_picker.vue +++ b/src/components/emoji_picker/emoji_picker.vue @@ -13,7 +13,10 @@ :title="group.text" @click.prevent="highlight(group.id)" > - <i :class="group.icon" /> + <FAIcon + :icon="group.icon" + fixed-width + /> </span> </span> <span @@ -26,7 +29,10 @@ :title="$t('emoji.stickers')" @click.prevent="toggleStickers" > - <i class="icon-star" /> + <FAIcon + icon="sticky-note" + fixed-width + /> </span> </span> </div> diff --git a/src/components/exporter/exporter.js b/src/components/exporter/exporter.js index 8f507416..51912ac3 100644 --- a/src/components/exporter/exporter.js +++ b/src/components/exporter/exporter.js @@ -1,3 +1,10 @@ +import { library } from '@fortawesome/fontawesome-svg-core' +import { faCircleNotch } from '@fortawesome/free-solid-svg-icons' + +library.add( + faCircleNotch +) + const Exporter = { props: { getContent: { diff --git a/src/components/exporter/exporter.vue b/src/components/exporter/exporter.vue index f5126dc1..ecd71bf1 100644 --- a/src/components/exporter/exporter.vue +++ b/src/components/exporter/exporter.vue @@ -1,7 +1,12 @@ <template> <div class="exporter"> <div v-if="processing"> - <i class="icon-spin4 animate-spin exporter-processing" /> + <FAIcon + icon="circle-notch" + size="lg" + spin + /> + <span>{{ processingMessage }}</span> </div> <button @@ -19,7 +24,6 @@ <style lang="scss"> .exporter { &-processing { - font-size: 1.5em; margin: 0.25em; } } diff --git a/src/components/extra_buttons/extra_buttons.js b/src/components/extra_buttons/extra_buttons.js index 5e0c36bb..1a8eef72 100644 --- a/src/components/extra_buttons/extra_buttons.js +++ b/src/components/extra_buttons/extra_buttons.js @@ -1,4 +1,24 @@ import Popover from '../popover/popover.vue' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faEllipsisH, + faBookmark, + faEyeSlash, + faThumbtack, + faShareAlt +} from '@fortawesome/free-solid-svg-icons' +import { + faBookmark as faBookmarkReg +} from '@fortawesome/free-regular-svg-icons' + +library.add( + faEllipsisH, + faBookmark, + faBookmarkReg, + faEyeSlash, + faThumbtack, + faShareAlt +) const ExtraButtons = { props: [ 'status' ], diff --git a/src/components/extra_buttons/extra_buttons.vue b/src/components/extra_buttons/extra_buttons.vue index 7a4e8642..a33f6e87 100644 --- a/src/components/extra_buttons/extra_buttons.vue +++ b/src/components/extra_buttons/extra_buttons.vue @@ -15,14 +15,20 @@ class="dropdown-item dropdown-item-icon" @click.prevent="muteConversation" > - <i class="icon-eye-off" /><span>{{ $t("status.mute_conversation") }}</span> + <FAIcon + fixed-width + icon="eye-slash" + /><span>{{ $t("status.mute_conversation") }}</span> </button> <button v-if="canMute && status.thread_muted" class="dropdown-item dropdown-item-icon" @click.prevent="unmuteConversation" > - <i class="icon-eye-off" /><span>{{ $t("status.unmute_conversation") }}</span> + <FAIcon + fixed-width + icon="eye-slash" + /><span>{{ $t("status.unmute_conversation") }}</span> </button> <button v-if="!status.pinned && canPin" @@ -30,7 +36,10 @@ @click.prevent="pinStatus" @click="close" > - <i class="icon-pin" /><span>{{ $t("status.pin") }}</span> + <FAIcon + fixed-width + icon="thumbtack" + /><span>{{ $t("status.pin") }}</span> </button> <button v-if="status.pinned && canPin" @@ -38,7 +47,10 @@ @click.prevent="unpinStatus" @click="close" > - <i class="icon-pin" /><span>{{ $t("status.unpin") }}</span> + <FAIcon + fixed-width + icon="thumbtack" + /><span>{{ $t("status.unpin") }}</span> </button> <button v-if="!status.bookmarked" @@ -46,7 +58,10 @@ @click.prevent="bookmarkStatus" @click="close" > - <i class="icon-bookmark-empty" /><span>{{ $t("status.bookmark") }}</span> + <FAIcon + fixed-width + :icon="['far', 'bookmark']" + /><span>{{ $t("status.bookmark") }}</span> </button> <button v-if="status.bookmarked" @@ -54,7 +69,10 @@ @click.prevent="unbookmarkStatus" @click="close" > - <i class="icon-bookmark" /><span>{{ $t("status.unbookmark") }}</span> + <FAIcon + fixed-width + icon="bookmark" + /><span>{{ $t("status.unbookmark") }}</span> </button> <button v-if="canDelete" @@ -62,21 +80,29 @@ @click.prevent="deleteStatus" @click="close" > - <i class="icon-cancel" /><span>{{ $t("status.delete") }}</span> + <FAIcon + fixed-width + icon="times" + /><span>{{ $t("status.delete") }}</span> </button> <button class="dropdown-item dropdown-item-icon" @click.prevent="copyLink" @click="close" > - <i class="icon-share" /><span>{{ $t("status.copy_link") }}</span> + <FAIcon + fixed-width + icon="share-alt" + /><span>{{ $t("status.copy_link") }}</span> </button> </div> </div> - <i - slot="trigger" - class="icon-ellipsis button-icon" - /> + <span slot="trigger"> + <FAIcon + class="ExtraButtons fa-scale-110 fa-old-padding" + icon="ellipsis-h" + /> + </span> </Popover> </template> @@ -85,8 +111,9 @@ <style lang="scss"> @import '../../_variables.scss'; -.icon-ellipsis { +.ExtraButtons { cursor: pointer; + position: static; &:hover, .extra-button-popover.open & { diff --git a/src/components/favorite_button/favorite_button.js b/src/components/favorite_button/favorite_button.js index 5014d84f..2a2ee84a 100644 --- a/src/components/favorite_button/favorite_button.js +++ b/src/components/favorite_button/favorite_button.js @@ -1,4 +1,14 @@ import { mapGetters } from 'vuex' +import { library } from '@fortawesome/fontawesome-svg-core' +import { faStar } from '@fortawesome/free-solid-svg-icons' +import { + faStar as faStarRegular +} from '@fortawesome/free-regular-svg-icons' + +library.add( + faStar, + faStarRegular +) const FavoriteButton = { props: ['status', 'loggedIn'], @@ -23,9 +33,7 @@ const FavoriteButton = { computed: { classes () { return { - 'icon-star-empty': !this.status.favorited, - 'icon-star': this.status.favorited, - 'animate-spin': this.animated + '-favorited': this.status.favorited } }, ...mapGetters(['mergedConfig']) diff --git a/src/components/favorite_button/favorite_button.vue b/src/components/favorite_button/favorite_button.vue index fbc90f84..dfe12f86 100644 --- a/src/components/favorite_button/favorite_button.vue +++ b/src/components/favorite_button/favorite_button.vue @@ -1,18 +1,21 @@ <template> <div v-if="loggedIn"> - <i + <FAIcon :class="classes" - class="button-icon favorite-button fav-active" + class="FavoriteButton fa-scale-110 fa-old-padding -interactive" :title="$t('tool_tip.favorite')" + :icon="[status.favorited ? 'fas' : 'far', 'star']" + :spin="animated" @click.prevent="favorite()" /> <span v-if="!mergedConfig.hidePostStats && status.fave_num > 0">{{ status.fave_num }}</span> </div> <div v-else> - <i + <FAIcon :class="classes" - class="button-icon favorite-button" + class="FavoriteButton fa-scale-110 fa-old-padding" :title="$t('tool_tip.favorite')" + :icon="['far', 'star']" /> <span v-if="!mergedConfig.hidePostStats && status.fave_num > 0">{{ status.fave_num }}</span> </div> @@ -23,18 +26,20 @@ <style lang="scss"> @import '../../_variables.scss'; -.fav-active { - cursor: pointer; - animation-duration: 0.6s; +.FavoriteButton { + &.-interactive { + cursor: pointer; + animation-duration: 0.6s; - &:hover { + &:hover { + color: $fallback--cOrange; + color: var(--cOrange, $fallback--cOrange); + } + } + + &.-favorited { color: $fallback--cOrange; color: var(--cOrange, $fallback--cOrange); } } - -.favorite-button.icon-star { - color: $fallback--cOrange; - color: var(--cOrange, $fallback--cOrange); -} </style> diff --git a/src/components/follow_requests/follow_requests.vue b/src/components/follow_requests/follow_requests.vue index 5fa4cf39..41f19db8 100644 --- a/src/components/follow_requests/follow_requests.vue +++ b/src/components/follow_requests/follow_requests.vue @@ -1,7 +1,9 @@ <template> <div class="settings panel panel-default"> <div class="panel-heading"> - {{ $t('nav.friend_requests') }} + <div class="title"> + {{ $t('nav.friend_requests') }} + </div> </div> <div class="panel-body"> <FollowRequestCard diff --git a/src/components/font_control/font_control.js b/src/components/font_control/font_control.js index 8e2b0e45..6274780b 100644 --- a/src/components/font_control/font_control.js +++ b/src/components/font_control/font_control.js @@ -1,4 +1,12 @@ import { set } from 'vue' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faChevronDown +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faChevronDown +) export default { props: [ diff --git a/src/components/font_control/font_control.vue b/src/components/font_control/font_control.vue index 61f0384b..dd117ec0 100644 --- a/src/components/font_control/font_control.vue +++ b/src/components/font_control/font_control.vue @@ -41,7 +41,10 @@ {{ option === 'custom' ? $t('settings.style.fonts.custom') : option }} </option> </select> - <i class="icon-down-open" /> + <FAIcon + class="select-down-icon" + icon="chevron-down" + /> </label> <input v-if="isCustom" diff --git a/src/components/global_notice_list/global_notice_list.js b/src/components/global_notice_list/global_notice_list.js index 3af29c23..e93fba75 100644 --- a/src/components/global_notice_list/global_notice_list.js +++ b/src/components/global_notice_list/global_notice_list.js @@ -1,3 +1,11 @@ +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faTimes +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faTimes +) const GlobalNoticeList = { computed: { diff --git a/src/components/global_notice_list/global_notice_list.vue b/src/components/global_notice_list/global_notice_list.vue index 0e4285cc..8a33b9eb 100644 --- a/src/components/global_notice_list/global_notice_list.vue +++ b/src/components/global_notice_list/global_notice_list.vue @@ -9,8 +9,9 @@ <div class="notice-message"> {{ $t(notice.messageKey, notice.messageArgs) }} </div> - <i - class="button-icon icon-cancel" + <FAIcon + class="fa-scale-110 fa-old-padding" + icon="times" @click="closeNotice(notice)" /> </div> diff --git a/src/components/image_cropper/image_cropper.js b/src/components/image_cropper/image_cropper.js index 01361e25..59e4d07e 100644 --- a/src/components/image_cropper/image_cropper.js +++ b/src/components/image_cropper/image_cropper.js @@ -1,5 +1,15 @@ import Cropper from 'cropperjs' import 'cropperjs/dist/cropper.css' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faTimes, + faCircleNotch +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faTimes, + faCircleNotch +) const ImageCropper = { props: { diff --git a/src/components/image_cropper/image_cropper.vue b/src/components/image_cropper/image_cropper.vue index 4e1b5927..75def612 100644 --- a/src/components/image_cropper/image_cropper.vue +++ b/src/components/image_cropper/image_cropper.vue @@ -31,9 +31,10 @@ @click="submit(false)" v-text="saveWithoutCroppingText" /> - <i + <FAIcon v-if="submitting" - class="icon-spin4 animate-spin" + spin + icon="circle-notch" /> </div> <div @@ -41,8 +42,9 @@ class="alert error" > {{ submitErrorMsg }} - <i - class="button-icon icon-cancel" + <FAIcon + class="fa-scale-110 fa-old-padding" + icon="times" @click="clearError" /> </div> diff --git a/src/components/importer/importer.js b/src/components/importer/importer.js index c5f9e4d2..59f9beb1 100644 --- a/src/components/importer/importer.js +++ b/src/components/importer/importer.js @@ -1,3 +1,14 @@ +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faCircleNotch, + faTimes +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faCircleNotch, + faTimes +) + const Importer = { props: { submitHandler: { diff --git a/src/components/importer/importer.vue b/src/components/importer/importer.vue index ed923d59..c4fe5b00 100644 --- a/src/components/importer/importer.vue +++ b/src/components/importer/importer.vue @@ -7,9 +7,11 @@ @change="change" > </form> - <i + <FAIcon v-if="submitting" - class="icon-spin4 animate-spin importer-uploading" + class="importer-uploading" + spin + icon="circle-notch" /> <button v-else @@ -19,15 +21,15 @@ {{ submitButtonLabel }} </button> <div v-if="success"> - <i - class="icon-cross" + <FAIcon + icon="times" @click="dismiss" /> <p>{{ successMessage }}</p> </div> <div v-else-if="error"> - <i - class="icon-cross" + <FAIcon + icon="times" @click="dismiss" /> <p>{{ errorMessage }}</p> diff --git a/src/components/interface_language_switcher/interface_language_switcher.vue b/src/components/interface_language_switcher/interface_language_switcher.vue index dd6800a3..d039e86b 100644 --- a/src/components/interface_language_switcher/interface_language_switcher.vue +++ b/src/components/interface_language_switcher/interface_language_switcher.vue @@ -19,7 +19,10 @@ {{ languageNames[i] }} </option> </select> - <i class="icon-down-open" /> + <FAIcon + class="select-down-icon" + icon="chevron-down" + /> </label> </div> </template> @@ -28,6 +31,14 @@ import languagesObject from '../../i18n/messages' import ISO6391 from 'iso-639-1' import _ from 'lodash' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faChevronDown +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faChevronDown +) export default { computed: { diff --git a/src/components/login_form/login_form.js b/src/components/login_form/login_form.js index 0d8f1da6..638bd812 100644 --- a/src/components/login_form/login_form.js +++ b/src/components/login_form/login_form.js @@ -1,5 +1,13 @@ import { mapState, mapGetters, mapActions, mapMutations } from 'vuex' import oauthApi from '../../services/new_api/oauth.js' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faTimes +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faTimes +) const LoginForm = { data: () => ({ diff --git a/src/components/login_form/login_form.vue b/src/components/login_form/login_form.vue index b4fdcefb..a1f77210 100644 --- a/src/components/login_form/login_form.vue +++ b/src/components/login_form/login_form.vue @@ -76,8 +76,9 @@ > <div class="alert error"> {{ error }} - <i - class="button-icon icon-cancel" + <FAIcon + class="fa-scale-110 fa-old-padding" + icon="times" @click="clearError" /> </div> diff --git a/src/components/media_modal/media_modal.js b/src/components/media_modal/media_modal.js index 24764e80..e7384c93 100644 --- a/src/components/media_modal/media_modal.js +++ b/src/components/media_modal/media_modal.js @@ -3,6 +3,16 @@ import VideoAttachment from '../video_attachment/video_attachment.vue' import Modal from '../modal/modal.vue' import fileTypeService from '../../services/file_type/file_type.service.js' import GestureService from '../../services/gesture_service/gesture_service' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faChevronLeft, + faChevronRight +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faChevronLeft, + faChevronRight +) const MediaModal = { components: { diff --git a/src/components/media_modal/media_modal.vue b/src/components/media_modal/media_modal.vue index 46931667..ea7f7a7f 100644 --- a/src/components/media_modal/media_modal.vue +++ b/src/components/media_modal/media_modal.vue @@ -34,7 +34,10 @@ class="modal-view-button-arrow modal-view-button-arrow--prev" @click.stop.prevent="goPrev" > - <i class="icon-left-open arrow-icon" /> + <FAIcon + class="arrow-icon" + icon="chevron-left" + /> </button> <button v-if="canNavigate" @@ -42,7 +45,10 @@ class="modal-view-button-arrow modal-view-button-arrow--next" @click.stop.prevent="goNext" > - <i class="icon-right-open arrow-icon" /> + <FAIcon + class="arrow-icon" + icon="chevron-right" + /> </button> </Modal> </template> diff --git a/src/components/media_upload/media_upload.js b/src/components/media_upload/media_upload.js index 7b8a76cc..669d8190 100644 --- a/src/components/media_upload/media_upload.js +++ b/src/components/media_upload/media_upload.js @@ -2,6 +2,14 @@ import statusPosterService from '../../services/status_poster/status_poster.service.js' import fileSizeFormatService from '../../services/file_size_format/file_size_format.js' +import { library } from '@fortawesome/fontawesome-svg-core' +import { faUpload, faCircleNotch } from '@fortawesome/free-solid-svg-icons' + +library.add( + faUpload, + faCircleNotch +) + const mediaUpload = { data () { return { diff --git a/src/components/media_upload/media_upload.vue b/src/components/media_upload/media_upload.vue index c8865d77..88251a26 100644 --- a/src/components/media_upload/media_upload.vue +++ b/src/components/media_upload/media_upload.vue @@ -7,13 +7,16 @@ class="label" :title="$t('tool_tip.media_upload')" > - <i + <FAIcon v-if="uploading" - class="progress-icon icon-spin4 animate-spin" + class="progress-icon" + icon="circle-notch" + spin /> - <i + <FAIcon v-if="!uploading" - class="new-icon icon-upload" + class="new-icon" + icon="upload" /> <input v-if="uploadReady" @@ -40,15 +43,5 @@ .new-icon { cursor: pointer; } - - .progress-icon { - display: inline-block; - line-height: 0; - &::before { - /* Overriding fontello to achieve the perfect speeeen */ - margin: 0; - line-height: 0; - } - } } </style> diff --git a/src/components/mfa_form/recovery_form.js b/src/components/mfa_form/recovery_form.js index b25c65dd..01a62a50 100644 --- a/src/components/mfa_form/recovery_form.js +++ b/src/components/mfa_form/recovery_form.js @@ -1,5 +1,13 @@ import mfaApi from '../../services/new_api/mfa.js' import { mapState, mapGetters, mapActions, mapMutations } from 'vuex' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faTimes +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faTimes +) export default { data: () => ({ diff --git a/src/components/mfa_form/recovery_form.vue b/src/components/mfa_form/recovery_form.vue index 57294630..78953649 100644 --- a/src/components/mfa_form/recovery_form.vue +++ b/src/components/mfa_form/recovery_form.vue @@ -54,8 +54,9 @@ > <div class="alert error"> {{ error }} - <i - class="button-icon icon-cancel" + <FAIcon + class="fa-scale-110 fa-old-padding" + icon="times" @click="clearError" /> </div> diff --git a/src/components/mfa_form/totp_form.js b/src/components/mfa_form/totp_form.js index b774f2d0..6ee823ed 100644 --- a/src/components/mfa_form/totp_form.js +++ b/src/components/mfa_form/totp_form.js @@ -1,5 +1,14 @@ import mfaApi from '../../services/new_api/mfa.js' import { mapState, mapGetters, mapActions, mapMutations } from 'vuex' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faTimes +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faTimes +) + export default { data: () => ({ code: null, diff --git a/src/components/mfa_form/totp_form.vue b/src/components/mfa_form/totp_form.vue index a344b395..9401cad5 100644 --- a/src/components/mfa_form/totp_form.vue +++ b/src/components/mfa_form/totp_form.vue @@ -56,8 +56,10 @@ > <div class="alert error"> {{ error }} - <i - class="button-icon icon-cancel" + <FAIcon + size="lg" + class="fa-scale-110 fa-old-padding" + icon="times" @click="clearError" /> </div> diff --git a/src/components/mobile_nav/mobile_nav.js b/src/components/mobile_nav/mobile_nav.js index b2b5d264..9e736cfb 100644 --- a/src/components/mobile_nav/mobile_nav.js +++ b/src/components/mobile_nav/mobile_nav.js @@ -3,6 +3,18 @@ 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' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faTimes, + faBell, + faBars +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faTimes, + faBell, + faBars +) const MobileNav = { components: { diff --git a/src/components/mobile_nav/mobile_nav.vue b/src/components/mobile_nav/mobile_nav.vue index abd95f09..5304a500 100644 --- a/src/components/mobile_nav/mobile_nav.vue +++ b/src/components/mobile_nav/mobile_nav.vue @@ -1,49 +1,53 @@ <template> - <div> + <div + class="MobileNav" + > <nav id="nav" - class="nav-bar container" + class="mobile-nav" :class="{ 'mobile-hidden': isChat }" + @click="scrollToTop()" > - <div - class="mobile-inner-nav" - @click="scrollToTop()" - > - <div class="item"> - <a - href="#" - class="mobile-nav-button" - @click.stop.prevent="toggleMobileSidebar()" - > - <i class="button-icon icon-menu" /> - <div - v-if="unreadChatCount" - class="alert-dot" - /> - </a> - <router-link - v-if="!hideSitename" - class="site-name" - :to="{ name: 'root' }" - active-class="home" - > - {{ sitename }} - </router-link> - </div> - <div class="item right"> - <a - v-if="currentUser" - class="mobile-nav-button" - href="#" - @click.stop.prevent="openMobileNotifications()" - > - <i class="button-icon icon-bell-alt" /> - <div - v-if="unseenNotificationsCount" - class="alert-dot" - /> - </a> - </div> + <div class="item"> + <a + href="#" + class="mobile-nav-button" + @click.stop.prevent="toggleMobileSidebar()" + > + <FAIcon + class="fa-scale-110 fa-old-padding" + icon="bars" + /> + <div + v-if="unreadChatCount" + class="alert-dot" + /> + </a> + <router-link + v-if="!hideSitename" + class="site-name" + :to="{ name: 'root' }" + active-class="home" + > + {{ sitename }} + </router-link> + </div> + <div class="item right"> + <a + v-if="currentUser" + class="mobile-nav-button" + href="#" + @click.stop.prevent="openMobileNotifications()" + > + <FAIcon + class="fa-scale-110 fa-old-padding" + icon="bell" + /> + <div + v-if="unseenNotificationsCount" + class="alert-dot" + /> + </a> </div> </nav> <div @@ -59,7 +63,10 @@ class="mobile-nav-button" @click.stop.prevent="closeMobileNotifications()" > - <i class="button-icon icon-cancel" /> + <FAIcon + class="fa-scale-110 fa-old-padding" + icon="times" + /> </a> </div> <div @@ -84,101 +91,124 @@ <style lang="scss"> @import '../../_variables.scss'; -.mobile-inner-nav { - width: 100%; - display: flex; - align-items: center; -} +.MobileNav { + .mobile-nav { + display: grid; + line-height: 50px; + height: 50px; + grid-template-rows: 50px; + grid-template-columns: 2fr auto; + width: 100%; + position: fixed; + box-sizing: border-box; + } -.mobile-nav-button { - display: flex; - justify-content: center; - width: 50px; - position: relative; - cursor: pointer; -} + .mobile-inner-nav { + width: 100%; + display: flex; + align-items: center; + } -.alert-dot { - border-radius: 100%; - height: 8px; - width: 8px; - position: absolute; - left: calc(50% - 4px); - top: calc(50% - 4px); - margin-left: 6px; - margin-top: -6px; - background-color: $fallback--cRed; - background-color: var(--badgeNotification, $fallback--cRed); -} + .mobile-nav-button { + display: inline-block; + text-align: center; + padding: 0 1em; + position: relative; + cursor: pointer; + } -.mobile-notifications-drawer { - width: 100%; - height: 100vh; - overflow-x: hidden; - position: fixed; - top: 0; - left: 0; - box-shadow: 1px 1px 4px rgba(0,0,0,.6); - box-shadow: var(--panelShadow); - transition-property: transform; - transition-duration: 0.25s; - transform: translateX(0); - z-index: 1001; - -webkit-overflow-scrolling: touch; - - &.closed { - transform: translateX(100%); + .site-name { + padding: 0 .3em; + display: inline-block; } -} -.mobile-notifications-header { - display: flex; - align-items: center; - justify-content: space-between; - z-index: 1; - width: 100%; - height: 50px; - line-height: 50px; - position: absolute; - color: var(--topBarText); - background-color: $fallback--fg; - background-color: var(--topBar, $fallback--fg); - box-shadow: 0px 0px 4px rgba(0,0,0,.6); - box-shadow: var(--topBarShadow); - - .title { - font-size: 1.3em; - margin-left: 0.6em; + .item { + /* moslty just to get rid of extra whitespaces */ + display: flex; } -} -.mobile-notifications { - margin-top: 50px; - width: 100vw; - height: calc(100vh - 50px); - overflow-x: hidden; - overflow-y: scroll; - - color: $fallback--text; - color: var(--text, $fallback--text); - background-color: $fallback--bg; - background-color: var(--bg, $fallback--bg); - - .notifications { - padding: 0; - border-radius: 0; - box-shadow: none; - .panel { - border-radius: 0; - margin: 0; - box-shadow: none; + .alert-dot { + border-radius: 100%; + height: 8px; + width: 8px; + position: absolute; + left: calc(50% - 4px); + top: calc(50% - 4px); + margin-left: 6px; + margin-top: -6px; + background-color: $fallback--cRed; + background-color: var(--badgeNotification, $fallback--cRed); + } + + .mobile-notifications-drawer { + width: 100%; + height: 100vh; + overflow-x: hidden; + position: fixed; + top: 0; + left: 0; + box-shadow: 1px 1px 4px rgba(0,0,0,.6); + box-shadow: var(--panelShadow); + transition-property: transform; + transition-duration: 0.25s; + transform: translateX(0); + z-index: 1001; + -webkit-overflow-scrolling: touch; + + &.closed { + transform: translateX(100%); } - .panel:after { - border-radius: 0; + } + + .mobile-notifications-header { + display: flex; + align-items: center; + justify-content: space-between; + z-index: 1; + width: 100%; + height: 50px; + line-height: 50px; + position: absolute; + color: var(--topBarText); + background-color: $fallback--fg; + background-color: var(--topBar, $fallback--fg); + box-shadow: 0px 0px 4px rgba(0,0,0,.6); + box-shadow: var(--topBarShadow); + + .title { + font-size: 1.3em; + margin-left: 0.6em; } - .panel .panel-heading { + } + + .mobile-notifications { + margin-top: 50px; + width: 100vw; + height: calc(100vh - 50px); + overflow-x: hidden; + overflow-y: scroll; + + color: $fallback--text; + color: var(--text, $fallback--text); + background-color: $fallback--bg; + background-color: var(--bg, $fallback--bg); + + .notifications { + padding: 0; border-radius: 0; box-shadow: none; + .panel { + border-radius: 0; + margin: 0; + box-shadow: none; + } + .panel:after { + border-radius: 0; + } + .panel .panel-heading { + border-radius: 0; + box-shadow: none; + } } } } 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 6348277b..366ea89c 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,4 +1,12 @@ import { debounce } from 'lodash' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faPen +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faPen +) const HIDDEN_FOR_PAGES = new Set([ 'chats', diff --git a/src/components/mobile_post_status_button/mobile_post_status_button.vue b/src/components/mobile_post_status_button/mobile_post_status_button.vue index 9cf45de3..50529878 100644 --- a/src/components/mobile_post_status_button/mobile_post_status_button.vue +++ b/src/components/mobile_post_status_button/mobile_post_status_button.vue @@ -5,7 +5,7 @@ :class="{ 'hidden': isHidden }" @click="openPostForm" > - <i class="icon-edit" /> + <FAIcon icon="pen" /> </button> </div> </template> @@ -39,7 +39,7 @@ transform: translateY(150%); } - i { + svg { font-size: 1.5em; color: $fallback--text; color: var(--text, $fallback--text); diff --git a/src/components/moderation_tools/moderation_tools.vue b/src/components/moderation_tools/moderation_tools.vue index b2d5acc5..60fa6ceb 100644 --- a/src/components/moderation_tools/moderation_tools.vue +++ b/src/components/moderation_tools/moderation_tools.vue @@ -178,7 +178,7 @@ box-shadow: var(--inputShadow); &.menu-checkbox-checked::after { - content: '✔'; + content: '✓'; } } diff --git a/src/components/nav_panel/nav_panel.js b/src/components/nav_panel/nav_panel.js index 623dfaec..81d49cc2 100644 --- a/src/components/nav_panel/nav_panel.js +++ b/src/components/nav_panel/nav_panel.js @@ -1,6 +1,29 @@ import { timelineNames } from '../timeline_menu/timeline_menu.js' import { mapState, mapGetters } from 'vuex' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faUsers, + faGlobe, + faBookmark, + faEnvelope, + faHome, + faComments, + faBell, + faInfoCircle +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faUsers, + faGlobe, + faBookmark, + faEnvelope, + faHome, + faComments, + faBell, + faInfoCircle +) + const NavPanel = { created () { if (this.currentUser && this.currentUser.locked) { diff --git a/src/components/nav_panel/nav_panel.vue b/src/components/nav_panel/nav_panel.vue index f8459fd1..0c83d0fe 100644 --- a/src/components/nav_panel/nav_panel.vue +++ b/src/components/nav_panel/nav_panel.vue @@ -1,5 +1,5 @@ <template> - <div class="nav-panel"> + <div class="NavPanel"> <div class="panel panel-default"> <ul> <li v-if="currentUser || !privateMode"> @@ -7,31 +7,47 @@ :to="{ name: timelinesRoute }" :class="onTimelineRoute && 'router-link-active'" > - <i class="button-icon icon-home-2" /> {{ $t("nav.timelines") }} + <FAIcon + fixed-width + class="fa-scale-110" + icon="home" + />{{ $t("nav.timelines") }} </router-link> </li> <li v-if="currentUser"> <router-link :to="{ name: 'interactions', params: { username: currentUser.screen_name } }"> - <i class="button-icon icon-bell-alt" /> {{ $t("nav.interactions") }} + <FAIcon + fixed-width + class="fa-scale-110" + icon="bell" + />{{ $t("nav.interactions") }} </router-link> </li> <li v-if="currentUser && pleromaChatMessagesAvailable"> <router-link :to="{ name: 'chats', params: { username: currentUser.screen_name } }"> <div v-if="unreadChatCount" - class="badge badge-notification unread-chat-count" + class="badge badge-notification" > {{ unreadChatCount }} </div> - <i class="button-icon icon-chat" /> {{ $t("nav.chats") }} + <FAIcon + fixed-width + class="fa-scale-110" + icon="comments" + />{{ $t("nav.chats") }} </router-link> </li> <li v-if="currentUser && currentUser.locked"> <router-link :to="{ name: 'friend-requests' }"> - <i class="button-icon icon-user-plus" /> {{ $t("nav.friend_requests") }} + <FAIcon + fixed-width + class="fa-scale-110" + icon="user-plus" + />{{ $t("nav.friend_requests") }} <span v-if="followRequestCount > 0" - class="badge follow-request-count" + class="badge badge-notification" > {{ followRequestCount }} </span> @@ -39,7 +55,11 @@ </li> <li> <router-link :to="{ name: 'about' }"> - <i class="button-icon icon-info-circled" /> {{ $t("nav.about") }} + <FAIcon + fixed-width + class="fa-scale-110" + icon="info-circle" + />{{ $t("nav.about") }} </router-link> </li> </ul> @@ -52,80 +72,88 @@ <style lang="scss"> @import '../../_variables.scss'; -.nav-panel .panel { - overflow: hidden; - box-shadow: var(--panelShadow); -} -.nav-panel ul { - list-style: none; - margin: 0; - padding: 0; -} - -.follow-request-count { - margin: -6px 10px; - background-color: $fallback--bg; - background-color: var(--input, $fallback--faint); -} - -.nav-panel li { - border-bottom: 1px solid; - border-color: $fallback--border; - border-color: var(--border, $fallback--border); - padding: 0; - - &:first-child a { - border-top-right-radius: $fallback--panelRadius; - border-top-right-radius: var(--panelRadius, $fallback--panelRadius); - border-top-left-radius: $fallback--panelRadius; - border-top-left-radius: var(--panelRadius, $fallback--panelRadius); +.NavPanel { + .panel { + overflow: hidden; + box-shadow: var(--panelShadow); } - &: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); + ul { + list-style: none; + margin: 0; + padding: 0; } -} -.nav-panel li:last-child { - border: none; -} + li { + position: relative; + border-bottom: 1px solid; + border-color: $fallback--border; + border-color: var(--border, $fallback--border); + padding: 0; -.nav-panel a { - display: block; - padding: 0.8em 0.85em; + &:first-child a { + border-top-right-radius: $fallback--panelRadius; + border-top-right-radius: var(--panelRadius, $fallback--panelRadius); + border-top-left-radius: $fallback--panelRadius; + border-top-left-radius: var(--panelRadius, $fallback--panelRadius); + } - &: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); + &: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); + } } - &.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); + li:last-child { + border: none; + } + + a { + display: block; + box-sizing: border-box; + align-items: stretch; + height: 3.5em; + line-height: 3.5em; + padding: 0 1em; &:hover { - text-decoration: underline; + 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; + } } } -} -.nav-panel .button-icon:before { - width: 1.1em; + .fa-scale-110 { + margin-right: 0.8em; + } + + .badge { + position: absolute; + right: 0.6rem; + top: 1.25em; + } } </style> diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js index bb906b50..4aa9affd 100644 --- a/src/components/notification/notification.js +++ b/src/components/notification/notification.js @@ -7,6 +7,28 @@ import Timeago from '../timeago/timeago.vue' import { isStatusNotification } from '../../services/notification_utils/notification_utils.js' import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faCheck, + faTimes, + faStar, + faRetweet, + faUserPlus, + faEyeSlash, + faUser, + faSuitcaseRolling +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faCheck, + faTimes, + faStar, + faRetweet, + faUserPlus, + faUser, + faEyeSlash, + faSuitcaseRolling +) const Notification = { data () { diff --git a/src/components/notification/notification.scss b/src/components/notification/notification.scss index d0e63d81..f5905560 100644 --- a/src/components/notification/notification.scss +++ b/src/components/notification/notification.scss @@ -1,3 +1,5 @@ +@import '../../_variables.scss'; + // TODO Copypaste from Status, should unify it somehow .Notification { &.-muted { @@ -49,4 +51,34 @@ display: block; } } + + .type-icon { + margin: 0 0.1em; + } + + &.-type--repeat .type-icon { + color: $fallback--cGreen; + color: var(--cGreen, $fallback--cGreen); + } + + &.-type--follow .type-icon { + color: $fallback--cBlue; + color: var(--cBlue, $fallback--cBlue); + } + + &.-type--follow-request .type-icon { + color: $fallback--cBlue; + color: var(--cBlue, $fallback--cBlue); + } + + &.-type--like .type-icon { + color: orange; + color: $fallback--cOrange; + color: var(--cOrange, $fallback--cOrange); + } + + &.-type--move .type-icon { + color: $fallback--cBlue; + color: var(--cBlue, $fallback--cBlue); + } } diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue index 7fac3840..2bbde108 100644 --- a/src/components/notification/notification.vue +++ b/src/components/notification/notification.vue @@ -1,5 +1,5 @@ <template> - <status + <Status v-if="notification.type === 'mention'" :compact="true" :statusoid="notification.status" @@ -18,12 +18,15 @@ href="#" class="unmute" @click.prevent="toggleMute" - ><i class="button-icon icon-eye-off" /></a> + ><FAIcon + class="fa-scale-110 fa-old-padding" + icon="eye-slash" + /></a> </div> <div v-else - class="non-mention" - :class="[userClass, { highlighted: userStyle }]" + class="Notification non-mention" + :class="[userClass, { highlighted: userStyle }, '-type--' + notification.type]" :style="[ userStyle ]" > <a @@ -60,26 +63,39 @@ :title="'@'+notification.from_profile.screen_name" >{{ notification.from_profile.name }}</span> <span v-if="notification.type === 'like'"> - <i class="fa icon-star lit" /> + <FAIcon + class="type-icon" + icon="star" + /> <small>{{ $t('notifications.favorited_you') }}</small> </span> <span v-if="notification.type === 'repeat'"> - <i - class="fa icon-retweet lit" + <FAIcon + class="type-icon" + icon="retweet" :title="$t('tool_tip.repeat')" /> <small>{{ $t('notifications.repeated_you') }}</small> </span> <span v-if="notification.type === 'follow'"> - <i class="fa icon-user-plus lit" /> + <FAIcon + class="type-icon" + icon="user-plus" + /> <small>{{ $t('notifications.followed_you') }}</small> </span> <span v-if="notification.type === 'follow_request'"> - <i class="fa icon-user lit" /> + <FAIcon + class="type-icon" + icon="user" + /> <small>{{ $t('notifications.follow_request') }}</small> </span> <span v-if="notification.type === 'move'"> - <i class="fa icon-arrow-curved lit" /> + <FAIcon + class="type-icon" + icon="suitcase-rolling" + /> <small>{{ $t('notifications.migrated_to') }}</small> </span> <span v-if="notification.type === 'pleroma:emoji_reaction'"> @@ -120,7 +136,10 @@ v-if="needMute" href="#" @click.prevent="toggleMute" - ><i class="button-icon icon-eye-off" /></a> + ><FAIcon + class="fa-scale-110 fa-old-padding" + icon="eye-slash" + /></a> </span> <div v-if="notification.type === 'follow' || notification.type === 'follow_request'" @@ -136,13 +155,15 @@ v-if="notification.type === 'follow_request'" style="white-space: nowrap;" > - <i - class="icon-ok button-icon follow-request-accept" + <FAIcon + icon="check" + class="fa-scale-110 fa-old-padding follow-request-accept" :title="$t('tool_tip.accept_follow_request')" @click="approveUser()" /> - <i - class="icon-cancel button-icon follow-request-reject" + <FAIcon + icon="times" + class="fa-scale-110 fa-old-padding follow-request-reject" :title="$t('tool_tip.reject_follow_request')" @click="denyUser()" /> diff --git a/src/components/notifications/notifications.js b/src/components/notifications/notifications.js index d951e2a8..4b479e13 100644 --- a/src/components/notifications/notifications.js +++ b/src/components/notifications/notifications.js @@ -6,6 +6,12 @@ import { filteredNotificationsFromStore, unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils.js' +import { library } from '@fortawesome/fontawesome-svg-core' +import { faCircleNotch } from '@fortawesome/free-solid-svg-icons' + +library.add( + faCircleNotch +) const DEFAULT_SEEN_TO_DISPLAY_COUNT = 30 diff --git a/src/components/notifications/notifications.scss b/src/components/notifications/notifications.scss index c6b2a5b5..682ae127 100644 --- a/src/components/notifications/notifications.scss +++ b/src/components/notifications/notifications.scss @@ -158,37 +158,6 @@ margin-right: .2em; } - .icon-retweet.lit { - color: $fallback--cGreen; - color: var(--cGreen, $fallback--cGreen); - } - - .icon-user.lit { - color: $fallback--cBlue; - color: var(--cBlue, $fallback--cBlue); - } - - .icon-user-plus.lit { - color: $fallback--cBlue; - color: var(--cBlue, $fallback--cBlue); - } - - .icon-reply.lit { - color: $fallback--cBlue; - color: var(--cBlue, $fallback--cBlue); - } - - .icon-star.lit { - color: orange; - color: $fallback--cOrange; - color: var(--cOrange, $fallback--cOrange); - } - - .icon-arrow-curved.lit { - color: $fallback--cBlue; - color: var(--cBlue, $fallback--cBlue); - } - .status-content { margin: 0; max-height: 300px; diff --git a/src/components/notifications/notifications.vue b/src/components/notifications/notifications.vue index d477a41b..bd875cca 100644 --- a/src/components/notifications/notifications.vue +++ b/src/components/notifications/notifications.vue @@ -61,7 +61,11 @@ v-else class="new-status-notification text-center panel-footer" > - <i class="icon-spin3 animate-spin" /> + <FAIcon + icon="circle-notch" + spin + size="lg" + /> </div> </div> </div> diff --git a/src/components/panel_loading/panel_loading.vue b/src/components/panel_loading/panel_loading.vue index 4efebb3c..d916d8a6 100644 --- a/src/components/panel_loading/panel_loading.vue +++ b/src/components/panel_loading/panel_loading.vue @@ -1,12 +1,27 @@ <template> <div class="panel-loading"> <span class="loading-text"> - <i class="icon-spin4 animate-spin" /> + <FAIcon + icon="circle-notch" + spin + size="3x" + /> {{ $t('general.loading') }} </span> </div> </template> +<script> +import { library } from '@fortawesome/fontawesome-svg-core' +import { faCircleNotch } from '@fortawesome/free-solid-svg-icons' + +library.add( + faCircleNotch +) + +export default {} +</script> + <style lang="scss"> @import 'src/_variables.scss'; @@ -18,8 +33,7 @@ font-size: 2em; color: $fallback--text; color: var(--text, $fallback--text); - .loading-text i { - font-size: 3em; + .loading-text svg { line-height: 0; vertical-align: middle; color: $fallback--text; diff --git a/src/components/password_reset/password_reset.js b/src/components/password_reset/password_reset.js index 5d21d720..3d94f5e7 100644 --- a/src/components/password_reset/password_reset.js +++ b/src/components/password_reset/password_reset.js @@ -1,5 +1,13 @@ import { mapState } from 'vuex' import passwordResetApi from '../../services/new_api/password_reset.js' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faTimes +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faTimes +) const passwordReset = { data: () => ({ diff --git a/src/components/password_reset/password_reset.vue b/src/components/password_reset/password_reset.vue index 713c9dce..0deb9ccf 100644 --- a/src/components/password_reset/password_reset.vue +++ b/src/components/password_reset/password_reset.vue @@ -63,10 +63,10 @@ > <span>{{ error }}</span> <a - class="button-icon dismiss" + class="fa-scale-110 fa-old-padding dismiss" @click.prevent="dismissError()" > - <i class="icon-cancel" /> + <FAIcon icon="times" /> </a> </p> </div> @@ -122,7 +122,7 @@ padding-right: 2rem; } - .icon-cancel { + .dismiss { cursor: pointer; } } diff --git a/src/components/poll/poll_form.js b/src/components/poll/poll_form.js index df93f038..1f8df3f9 100644 --- a/src/components/poll/poll_form.js +++ b/src/components/poll/poll_form.js @@ -1,5 +1,17 @@ import * as DateUtils from 'src/services/date_utils/date_utils.js' import { uniq } from 'lodash' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faTimes, + faChevronDown, + faPlus +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faTimes, + faChevronDown, + faPlus +) export default { name: 'PollForm', diff --git a/src/components/poll/poll_form.vue b/src/components/poll/poll_form.vue index d53f3837..31f204a0 100644 --- a/src/components/poll/poll_form.vue +++ b/src/components/poll/poll_form.vue @@ -12,6 +12,7 @@ <input :id="`poll-${index}`" v-model="options[index]" + size="1" class="poll-option-input" type="text" :placeholder="$t('polls.option')" @@ -24,8 +25,9 @@ v-if="options.length > 2" class="icon-container" > - <i - class="icon-cancel" + <FAIcon + icon="times" + class="delete" @click="deleteOption(index)" /> </div> @@ -35,7 +37,11 @@ class="add-option faint" @click="addOption" > - <i class="icon-plus" /> + <FAIcon + icon="plus" + size="sm" + /> + {{ $t("polls.add_option") }} </a> <div class="poll-type-expiry"> @@ -55,7 +61,10 @@ <option value="single">{{ $t('polls.single_choice') }}</option> <option value="multiple">{{ $t('polls.multiple_choices') }}</option> </select> - <i class="icon-down-open" /> + <FAIcon + class="select-down-icon" + icon="chevron-down" + /> </label> </div> <div @@ -83,7 +92,10 @@ {{ $t(`time.${unit}_short`, ['']) }} </option> </select> - <i class="icon-down-open" /> + <FAIcon + class="select-down-icon" + icon="chevron-down" + /> </label> </div> </div> @@ -103,6 +115,7 @@ .add-option { align-self: flex-start; padding-top: 0.25em; + padding-left: 0.1em; cursor: pointer; } @@ -124,9 +137,17 @@ .icon-container { // Hack: Move the icon over the input box - width: 2em; - margin-left: -2em; + width: 1.5em; + margin-left: -1.5em; z-index: 1; + + .delete { + cursor: pointer; + + &:hover { + color: inherit; + } + } } .poll-type-expiry { diff --git a/src/components/popover/popover.vue b/src/components/popover/popover.vue index 5c99c509..9b8680e5 100644 --- a/src/components/popover/popover.vue +++ b/src/components/popover/popover.vue @@ -27,7 +27,7 @@ <script src="./popover.js" /> -<style lang=scss> +<style lang="scss"> @import '../../_variables.scss'; .popover { @@ -96,7 +96,7 @@ &-icon { padding-left: 0.5rem; - i { + svg { margin-right: 0.25rem; color: var(--menuPopoverIcon, $fallback--icon) } @@ -111,7 +111,7 @@ --faintLink: var(--selectedMenuPopoverFaintLink, $fallback--faint); --lightText: var(--selectedMenuPopoverLightText, $fallback--lightText); --icon: var(--selectedMenuPopoverIcon, $fallback--icon); - i { + svg { color: var(--selectedMenuPopoverIcon, $fallback--icon); } } diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js index ad149506..de583269 100644 --- a/src/components/post_status_form/post_status_form.js +++ b/src/components/post_status_form/post_status_form.js @@ -12,6 +12,27 @@ import suggestor from '../emoji_input/suggestor.js' import { mapGetters, mapState } from 'vuex' import Checkbox from '../checkbox/checkbox.vue' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faChevronDown, + faSmileBeam, + faPollH, + faUpload, + faBan, + faTimes, + faCircleNotch +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faChevronDown, + faSmileBeam, + faPollH, + faUpload, + faBan, + faTimes, + faCircleNotch +) + const buildMentionsString = ({ user, attentions = [] }, currentUser) => { let allAttentions = [...attentions] @@ -54,7 +75,8 @@ const PostStatusForm = { 'autoFocus', 'fileLimit', 'submitOnEnter', - 'emojiPickerPlacement' + 'emojiPickerPlacement', + 'optimisticPosting' ], components: { MediaUpload, @@ -251,7 +273,7 @@ const PostStatusForm = { if (this.preview) this.previewStatus() }, async postStatus (event, newStatus, opts = {}) { - if (this.posting) { return } + if (this.posting && !this.optimisticPosting) { return } if (this.disableSubmit) { return } if (this.emojiInputShown) { return } if (this.submitOnEnter) { @@ -259,6 +281,8 @@ const PostStatusForm = { event.preventDefault() } + if (this.optimisticPosting && (this.emptyStatus || this.isOverLengthLimit)) { return } + if (this.emptyStatus) { this.error = this.$t('post_status.empty_status_error') return diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue index d67d9ae9..42d3152b 100644 --- a/src/components/post_status_form/post_status_form.vue +++ b/src/components/post_status_form/post_status_form.vue @@ -12,10 +12,11 @@ v-show="showDropIcon !== 'hide'" :style="{ animation: showDropIcon === 'show' ? 'fade-in 0.25s' : 'fade-out 0.5s' }" class="drop-indicator" - :class="[uploadFileLimitReached ? 'icon-block' : 'icon-upload']" @dragleave="fileDragStop" @drop.stop="fileDrop" - /> + > + <FAIcon :icon="uploadFileLimitReached ? 'ban' : 'upload'" /> + </div> <div class="form-group"> <i18n v-if="!$store.state.users.currentUser.locked && newStatus.visibility == 'private' && !disableLockWarning" @@ -36,10 +37,10 @@ > <span>{{ $t('post_status.scope_notice.public') }}</span> <a - class="button-icon dismiss" + class="fa-scale-110 fa-old-padding dismiss" @click.prevent="dismissScopeNotice()" > - <i class="icon-cancel" /> + <FAIcon icon="times" /> </a> </p> <p @@ -48,10 +49,10 @@ > <span>{{ $t('post_status.scope_notice.unlisted') }}</span> <a - class="button-icon dismiss" + class="fa-scale-110 fa-old-padding dismiss" @click.prevent="dismissScopeNotice()" > - <i class="icon-cancel" /> + <FAIcon icon="times" /> </a> </p> <p @@ -60,10 +61,10 @@ > <span>{{ $t('post_status.scope_notice.private') }}</span> <a - class="button-icon dismiss" + class="fa-scale-110 fa-old-padding dismiss" @click.prevent="dismissScopeNotice()" > - <i class="icon-cancel" /> + <FAIcon icon="times" /> </a> </p> <p @@ -82,12 +83,18 @@ @click.stop.prevent="togglePreview" > {{ $t('post_status.preview') }} - <i :class="showPreview ? 'icon-left-open' : 'icon-right-open'" /> + <FAIcon :icon="showPreview ? 'chevron-left' : 'chevron-right'" /> </a> - <i + <div v-show="previewLoading" - class="icon-spin3 animate-spin" - /> + class="preview-spinner" + > + <FAIcon + class="fa-old-padding" + spin + icon="circle-notch" + /> + </div> </div> <div v-if="showPreview" @@ -122,7 +129,8 @@ v-model="newStatus.spoilerText" type="text" :placeholder="$t('post_status.content_warning')" - :disabled="posting" + :disabled="posting && !optimisticPosting" + size="1" class="form-post-subject" > </EmojiInput> @@ -147,7 +155,7 @@ :placeholder="placeholder || $t('post_status.default')" rows="1" cols="1" - :disabled="posting" + :disabled="posting && !optimisticPosting" class="form-post-body" :class="{ 'scrollable-form': !!maxHeight }" @keydown.exact.enter="submitOnEnter && postStatus($event, newStatus)" @@ -198,7 +206,10 @@ {{ $t(`post_status.content_type["${postFormat}"]`) }} </option> </select> - <i class="icon-down-open" /> + <FAIcon + class="select-down-icon" + icon="chevron-down" + /> </label> </div> <div @@ -235,22 +246,22 @@ <div class="emoji-icon" > - <i + <div :title="$t('emoji.add_emoji')" - class="icon-smile btn btn-default" + class="btn btn-default" @click="showEmojiPicker" - /> + > + <FAIcon icon="smile-beam" /> + </div> </div> <div v-if="pollsAvailable" class="poll-icon" :class="{ selected: pollFormVisible }" + :title="$t('polls.add_poll')" + @click="togglePollForm" > - <i - :title="$t('polls.add_poll')" - class="icon-chart-bar btn btn-default" - @click="togglePollForm" - /> + <FAIcon icon="poll-h" /> </div> </div> <button @@ -283,8 +294,9 @@ class="alert error" > Error: {{ error }} - <i - class="button-icon icon-cancel" + <FAIcon + class="fa-scale-110 fa-old-padding" + icon="times" @click="clearError" /> </div> @@ -294,8 +306,9 @@ :key="file.url" class="media-upload-wrapper" > - <i - class="fa button-icon icon-cancel" + <FAIcon + class="fa-scale-110 fa-old-padding" + icon="times" @click="removeMediaFile(file)" /> <attachment @@ -375,24 +388,19 @@ } .preview-heading { - padding-left: 0.5em; display: flex; - width: 100%; - - .icon-spin3 { - margin-left: auto; - } + padding-left: 0.5em; } .preview-toggle { - display: flex; + flex: 1; cursor: pointer; user-select: none; &:hover { text-decoration: underline; } - i { + svg, i { margin-left: 0.2em; font-size: 0.8em; transform: rotate(90deg); @@ -434,18 +442,20 @@ .media-upload-icon, .poll-icon, .emoji-icon { font-size: 26px; + line-height: 1.1; flex: 1; + padding: 0 0.1em; &.selected, &:hover { // needs to be specific to override icon default color - i, label { + svg, i, label { color: $fallback--lightText; color: var(--lightText, $fallback--lightText); } } &.disabled { - i { + svg, i { cursor: not-allowed; color: $fallback--icon; color: var(--btnDisabledText, $fallback--icon); @@ -474,7 +484,7 @@ text-align: right; } - .icon-chart-bar { + .poll-icon { cursor: pointer; } @@ -487,19 +497,6 @@ margin-bottom: .5em; width: 18em; - .icon-cancel { - display: inline-block; - position: static; - margin: 0; - padding-bottom: 0; - margin-left: $fallback--attachmentRadius; - margin-left: var(--attachmentRadius, $fallback--attachmentRadius); - background-color: $fallback--fg; - background-color: var(--btn, $fallback--fg); - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - } - img, video { object-fit: contain; max-height: 10em; @@ -522,7 +519,7 @@ flex-direction: column; } - .media-upload-wrapper .attachments { + .attachments .media-upload-wrapper { padding: 0 0.5em; .attachment { @@ -531,14 +528,18 @@ position: relative; } - i { + .fa-scale-110 fa-old-padding { position: absolute; margin: 10px; - padding: 5px; + margin: .75em; + padding: .5em; background: rgba(230,230,230,0.6); + z-index: 2; + color: black; border-radius: $fallback--attachmentRadius; border-radius: var(--attachmentRadius, $fallback--attachmentRadius); font-weight: bold; + cursor: pointer; } } @@ -612,11 +613,6 @@ cursor: not-allowed; } - .icon-cancel { - cursor: pointer; - z-index: 4; - } - @keyframes fade-in { from { opacity: 0; } to { opacity: 0.6; } diff --git a/src/components/react_button/react_button.js b/src/components/react_button/react_button.js index abcf0455..de0df70c 100644 --- a/src/components/react_button/react_button.js +++ b/src/components/react_button/react_button.js @@ -1,5 +1,8 @@ import Popover from '../popover/popover.vue' -import { mapGetters } from 'vuex' +import { library } from '@fortawesome/fontawesome-svg-core' +import { faSmileBeam } from '@fortawesome/free-regular-svg-icons' + +library.add(faSmileBeam) const ReactButton = { props: ['status'], @@ -29,13 +32,23 @@ const ReactButton = { emojis () { if (this.filterWord !== '') { const filterWordLowercase = this.filterWord.toLowerCase() - return this.$store.state.instance.emoji.filter(emoji => - emoji.displayText.toLowerCase().includes(filterWordLowercase) - ) + let orderedEmojiList = [] + for (const emoji of this.$store.state.instance.emoji) { + const indexOfFilterWord = emoji.displayText.toLowerCase().indexOf(filterWordLowercase) + if (indexOfFilterWord > -1) { + if (!Array.isArray(orderedEmojiList[indexOfFilterWord])) { + orderedEmojiList[indexOfFilterWord] = [] + } + orderedEmojiList[indexOfFilterWord].push(emoji) + } + } + return orderedEmojiList.flat() } return this.$store.state.instance.emoji || [] }, - ...mapGetters(['mergedConfig']) + mergedConfig () { + return this.$store.getters.mergedConfig + } } } diff --git a/src/components/react_button/react_button.vue b/src/components/react_button/react_button.vue index 0b34add1..95d95b11 100644 --- a/src/components/react_button/react_button.vue +++ b/src/components/react_button/react_button.vue @@ -12,6 +12,7 @@ <div class="reaction-picker-filter"> <input v-model="filterWord" + size="1" :placeholder="$t('emoji.search_emoji')" > </div> @@ -36,9 +37,10 @@ <div class="reaction-bottom-fader" /> </div> </div> - <i + <FAIcon slot="trigger" - class="icon-smile button-icon add-reaction-button" + class="fa-scale-110 fa-old-padding add-reaction-button" + :icon="['far', 'smile-beam']" :title="$t('tool_tip.add_reaction')" /> </Popover> diff --git a/src/components/reply_button/reply_button.js b/src/components/reply_button/reply_button.js new file mode 100644 index 00000000..c7bd2a2b --- /dev/null +++ b/src/components/reply_button/reply_button.js @@ -0,0 +1,16 @@ +import { library } from '@fortawesome/fontawesome-svg-core' +import { faReply } from '@fortawesome/free-solid-svg-icons' + +library.add(faReply) + +const ReplyButton = { + name: 'ReplyButton', + props: ['status', 'replying'], + computed: { + loggedIn () { + return !!this.$store.state.users.currentUser + } + } +} + +export default ReplyButton diff --git a/src/components/reply_button/reply_button.vue b/src/components/reply_button/reply_button.vue new file mode 100644 index 00000000..a0ac8941 --- /dev/null +++ b/src/components/reply_button/reply_button.vue @@ -0,0 +1,39 @@ +<template> + <div> + <FAIcon + v-if="loggedIn" + class="ReplyButton fa-scale-110 fa-old-padding -interactive" + icon="reply" + :title="$t('tool_tip.reply')" + :class="{'-active': replying}" + @click.prevent="$emit('toggle')" + /> + <FAIcon + v-else + icon="reply" + class="ReplyButton fa-scale-110 fa-old-padding" + :title="$t('tool_tip.reply')" + /> + <span v-if="status.replies_count > 0"> + {{ status.replies_count }} + </span> + </div> +</template> + +<script src="./reply_button.js"></script> + +<style lang="scss"> +@import '../../_variables.scss'; + +.ReplyButton { + &.-interactive { + cursor: pointer; + + &:hover, + &.-active { + color: $fallback--cBlue; + color: var(--cBlue, $fallback--cBlue); + } + } +} +</style> diff --git a/src/components/retweet_button/retweet_button.js b/src/components/retweet_button/retweet_button.js index d9a0f92e..5ee4179a 100644 --- a/src/components/retweet_button/retweet_button.js +++ b/src/components/retweet_button/retweet_button.js @@ -1,4 +1,7 @@ -import { mapGetters } from 'vuex' +import { library } from '@fortawesome/fontawesome-svg-core' +import { faRetweet } from '@fortawesome/free-solid-svg-icons' + +library.add(faRetweet) const RetweetButton = { props: ['status', 'loggedIn', 'visibility'], @@ -23,12 +26,12 @@ const RetweetButton = { computed: { classes () { return { - 'retweeted': this.status.repeated, - 'retweeted-empty': !this.status.repeated, - 'animate-spin': this.animated + '-repeated': this.status.repeated } }, - ...mapGetters(['mergedConfig']) + mergedConfig () { + return this.$store.getters.mergedConfig + } } } diff --git a/src/components/retweet_button/retweet_button.vue b/src/components/retweet_button/retweet_button.vue index 074f7747..b234f3d9 100644 --- a/src/components/retweet_button/retweet_button.vue +++ b/src/components/retweet_button/retweet_button.vue @@ -1,26 +1,30 @@ <template> <div v-if="loggedIn"> <template v-if="visibility !== 'private' && visibility !== 'direct'"> - <i + <FAIcon :class="classes" - class="button-icon retweet-button icon-retweet rt-active" + class="RetweetButton fa-scale-110 fa-old-padding -interactive" + icon="retweet" + :spin="animated" :title="$t('tool_tip.repeat')" @click.prevent="retweet()" /> <span v-if="!mergedConfig.hidePostStats && status.repeat_num > 0">{{ status.repeat_num }}</span> </template> <template v-else> - <i + <FAIcon :class="classes" - class="button-icon icon-lock" + class="RetweetButton fa-scale-110 fa-old-padding" + icon="lock" :title="$t('timeline.no_retweet_hint')" /> </template> </div> <div v-else-if="!loggedIn"> - <i + <FAIcon :class="classes" - class="button-icon icon-retweet" + class="fa-scale-110 fa-old-padding" + icon="retweet" :title="$t('tool_tip.repeat')" /> <span v-if="!mergedConfig.hidePostStats && status.repeat_num > 0">{{ status.repeat_num }}</span> @@ -31,16 +35,21 @@ <style lang="scss"> @import '../../_variables.scss'; -.rt-active { - cursor: pointer; - animation-duration: 0.6s; - &:hover { + +.RetweetButton { + &.-interactive { + cursor: pointer; + animation-duration: 0.6s; + + &:hover { + color: $fallback--cGreen; + color: var(--cGreen, $fallback--cGreen); + } + } + + &.-repeated { color: $fallback--cGreen; color: var(--cGreen, $fallback--cGreen); } } -.icon-retweet.retweeted { - color: $fallback--cGreen; - color: var(--cGreen, $fallback--cGreen); -} </style> diff --git a/src/components/scope_selector/scope_selector.js b/src/components/scope_selector/scope_selector.js index e9ccdefc..74bf7284 100644 --- a/src/components/scope_selector/scope_selector.js +++ b/src/components/scope_selector/scope_selector.js @@ -1,3 +1,18 @@ +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faEnvelope, + faLock, + faLockOpen, + faGlobe +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faEnvelope, + faGlobe, + faLock, + faLockOpen +) + const ScopeSelector = { props: [ 'showAll', diff --git a/src/components/scope_selector/scope_selector.vue b/src/components/scope_selector/scope_selector.vue index 291236f2..a22a4fda 100644 --- a/src/components/scope_selector/scope_selector.vue +++ b/src/components/scope_selector/scope_selector.vue @@ -1,36 +1,56 @@ <template> <div v-if="!showNothing" - class="scope-selector" + class="ScopeSelector" > - <i + <span v-if="showDirect" - class="icon-mail-alt" + class="scope" :class="css.direct" :title="$t('post_status.scope.direct')" @click="changeVis('direct')" - /> - <i + > + <FAIcon + icon="envelope" + class="fa-scale-110 fa-old-padding" + /> + </span> + <span v-if="showPrivate" - class="icon-lock" + class="scope" :class="css.private" :title="$t('post_status.scope.private')" @click="changeVis('private')" - /> - <i + > + <FAIcon + icon="lock" + class="fa-scale-110 fa-old-padding" + /> + </span> + <span v-if="showUnlisted" - class="icon-lock-open-alt" + class="scope" :class="css.unlisted" :title="$t('post_status.scope.unlisted')" @click="changeVis('unlisted')" - /> - <i + > + <FAIcon + icon="lock-open" + class="fa-scale-110 fa-old-padding" + /> + </span> + <span v-if="showPublic" - class="icon-globe" + class="scope" :class="css.public" :title="$t('post_status.scope.public')" @click="changeVis('public')" - /> + > + <FAIcon + icon="globe" + class="fa-scale-110 fa-old-padding" + /> + </span> </div> </template> @@ -39,12 +59,16 @@ <style lang="scss"> @import '../../_variables.scss'; -.scope-selector { - i { - font-size: 1.2em; +.ScopeSelector { + + .scope { + display: inline-block; cursor: pointer; + min-width: 1.3em; + min-height: 1.3em; + text-align: center; - &.selected { + &.selected svg { color: $fallback--lightText; color: var(--lightText, $fallback--lightText); } diff --git a/src/components/search/search.js b/src/components/search/search.js index 8e903052..b62bc2c5 100644 --- a/src/components/search/search.js +++ b/src/components/search/search.js @@ -2,6 +2,16 @@ import FollowCard from '../follow_card/follow_card.vue' import Conversation from '../conversation/conversation.vue' import Status from '../status/status.vue' import map from 'lodash/map' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faCircleNotch, + faSearch +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faCircleNotch, + faSearch +) const Search = { components: { diff --git a/src/components/search/search.vue b/src/components/search/search.vue index 746bbaa2..665390f9 100644 --- a/src/components/search/search.vue +++ b/src/components/search/search.vue @@ -17,14 +17,18 @@ class="btn search-button" @click="newQuery(searchTerm)" > - <i class="icon-search" /> + <FAIcon icon="search" /> </button> </div> <div v-if="loading" class="text-center loading-icon" > - <i class="icon-spin3 animate-spin" /> + <FAIcon + icon="circle-notch" + spin + size="lg" + /> </div> <div v-else-if="loaded"> <div class="search-nav-heading"> diff --git a/src/components/search_bar/search_bar.js b/src/components/search_bar/search_bar.js index d7d85676..551649c7 100644 --- a/src/components/search_bar/search_bar.js +++ b/src/components/search_bar/search_bar.js @@ -1,9 +1,19 @@ +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faTimes, + faSearch +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faTimes, + faSearch +) + const SearchBar = { data: () => ({ searchTerm: undefined, hidden: true, - error: false, - loading: false + error: false }), watch: { '$route': function (route) { diff --git a/src/components/search_bar/search_bar.vue b/src/components/search_bar/search_bar.vue index 4d5a1aec..89a601c8 100644 --- a/src/components/search_bar/search_bar.vue +++ b/src/components/search_bar/search_bar.vue @@ -1,40 +1,47 @@ <template> - <div> - <div class="search-bar-container"> - <i - v-if="loading" - class="icon-spin4 finder-icon animate-spin-slow" - /> - <a - v-if="hidden" - href="#" - :title="$t('nav.search')" - ><i - class="button-icon icon-search" - @click.prevent.stop="toggleHidden" - /></a> - <template v-else> - <input - id="search-bar-input" - ref="searchInput" - v-model="searchTerm" - class="search-bar-input" - :placeholder="$t('nav.search')" - type="text" - @keyup.enter="find(searchTerm)" - > - <button - class="btn search-button" - @click="find(searchTerm)" - > - <i class="icon-search" /> - </button> - <i - class="button-icon icon-cancel" + <div + class="SearchBar" + :class="{ '-expanded': !hidden }" + > + <a + v-if="hidden" + href="#" + class="nav-icon" + :title="$t('nav.search')" + ><FAIcon + fixed-width + class="fa-scale-110 fa-old-padding" + icon="search" + @click.prevent.stop="toggleHidden" + /></a> + <template v-else> + <input + id="search-bar-input" + ref="searchInput" + v-model="searchTerm" + class="search-bar-input" + :placeholder="$t('nav.search')" + type="text" + @keyup.enter="find(searchTerm)" + > + <button + class="btn search-button" + @click="find(searchTerm)" + > + <FAIcon + fixed-width + icon="search" + /> + </button> + <span> + <FAIcon + fixed-width + icon="times" + class="cancel-icon fa-scale-110 fa-old-padding" @click.prevent.stop="toggleHidden" /> - </template> - </div> + </span> + </template> </div> </template> @@ -43,30 +50,29 @@ <style lang="scss"> @import '../../_variables.scss'; -.search-bar-container { - max-width: 100%; +.SearchBar { display: inline-flex; align-items: baseline; vertical-align: baseline; justify-content: flex-end; + &.-expanded { + width: 100%; + } + .search-bar-input, .search-button { height: 29px; } .search-bar-input { - // TODO: do this properly without a rough guesstimate of 2 icons + paddings - max-width: calc(100% - 30px - 30px - 20px); - } - - .search-button { - margin-left: .5em; - margin-right: .5em; + flex: 1 0 auto; } - .icon-cancel { + .cancel-icon { cursor: pointer; + color: $fallback--text; + color: var(--btnTopBarText, $fallback--text); } } diff --git a/src/components/settings_modal/settings_modal_content.js b/src/components/settings_modal/settings_modal_content.js index ef1a5ffa..9dcf1b5a 100644 --- a/src/components/settings_modal/settings_modal_content.js +++ b/src/components/settings_modal/settings_modal_content.js @@ -10,6 +10,29 @@ import GeneralTab from './tabs/general_tab.vue' import VersionTab from './tabs/version_tab.vue' import ThemeTab from './tabs/theme_tab/theme_tab.vue' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faWrench, + faUser, + faFilter, + faPaintBrush, + faBell, + faDownload, + faEyeSlash, + faInfo +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faWrench, + faUser, + faFilter, + faPaintBrush, + faBell, + faDownload, + faEyeSlash, + faInfo +) + const SettingsModalContent = { components: { TabSwitcher, diff --git a/src/components/settings_modal/settings_modal_content.scss b/src/components/settings_modal/settings_modal_content.scss index a3fef1cf..f066234c 100644 --- a/src/components/settings_modal/settings_modal_content.scss +++ b/src/components/settings_modal/settings_modal_content.scss @@ -31,7 +31,7 @@ } .unavailable, - .unavailable i { + .unavailable svg { color: var(--cRed, $fallback--cRed); color: $fallback--cRed; } diff --git a/src/components/settings_modal/settings_modal_content.vue b/src/components/settings_modal/settings_modal_content.vue index bc30a0ff..c9ed2a38 100644 --- a/src/components/settings_modal/settings_modal_content.vue +++ b/src/components/settings_modal/settings_modal_content.vue @@ -37,7 +37,7 @@ </div> <div :label="$t('settings.theme')" - icon="brush" + icon="paint-brush" data-tab-name="theme" > <ThemeTab /> @@ -45,7 +45,7 @@ <div v-if="isLoggedIn" :label="$t('settings.notifications')" - icon="bell-ringing-o" + icon="bell" data-tab-name="notifications" > <NotificationsTab /> @@ -62,14 +62,14 @@ v-if="isLoggedIn" :label="$t('settings.mutes_and_blocks')" :fullHeight="true" - icon="eye-off" + icon="eye-slash" data-tab-name="mutesAndBlocks" > <MutesAndBlocksTab /> </div> <div :label="$t('settings.version.title')" - icon="info-circled" + icon="info" data-tab-name="version" > <VersionTab /> diff --git a/src/components/settings_modal/tabs/data_import_export_tab.js b/src/components/settings_modal/tabs/data_import_export_tab.js index 168f89e1..f4b736d2 100644 --- a/src/components/settings_modal/tabs/data_import_export_tab.js +++ b/src/components/settings_modal/tabs/data_import_export_tab.js @@ -1,6 +1,7 @@ import Importer from 'src/components/importer/importer.vue' import Exporter from 'src/components/exporter/exporter.vue' import Checkbox from 'src/components/checkbox/checkbox.vue' +import { mapState } from 'vuex' const DataImportExportTab = { data () { @@ -18,21 +19,26 @@ const DataImportExportTab = { Checkbox }, computed: { - user () { - return this.$store.state.users.currentUser - } + ...mapState({ + backendInteractor: (state) => state.api.backendInteractor, + user: (state) => state.users.currentUser + }) }, methods: { getFollowsContent () { - return this.$store.state.api.backendInteractor.exportFriends({ id: this.$store.state.users.currentUser.id }) + return this.backendInteractor.exportFriends({ id: this.user.id }) .then(this.generateExportableUsersContent) }, getBlocksContent () { - return this.$store.state.api.backendInteractor.fetchBlocks() + return this.backendInteractor.fetchBlocks() + .then(this.generateExportableUsersContent) + }, + getMutesContent () { + return this.backendInteractor.fetchMutes() .then(this.generateExportableUsersContent) }, importFollows (file) { - return this.$store.state.api.backendInteractor.importFollows({ file }) + return this.backendInteractor.importFollows({ file }) .then((status) => { if (!status) { throw new Error('failed') @@ -40,7 +46,15 @@ const DataImportExportTab = { }) }, importBlocks (file) { - return this.$store.state.api.backendInteractor.importBlocks({ file }) + return this.backendInteractor.importBlocks({ file }) + .then((status) => { + if (!status) { + throw new Error('failed') + } + }) + }, + importMutes (file) { + return this.backendInteractor.importMutes({ file }) .then((status) => { if (!status) { throw new Error('failed') diff --git a/src/components/settings_modal/tabs/data_import_export_tab.vue b/src/components/settings_modal/tabs/data_import_export_tab.vue index b5d0f5ed..a406077d 100644 --- a/src/components/settings_modal/tabs/data_import_export_tab.vue +++ b/src/components/settings_modal/tabs/data_import_export_tab.vue @@ -36,6 +36,23 @@ :export-button-label="$t('settings.block_export_button')" /> </div> + <div class="setting-item"> + <h2>{{ $t('settings.mute_import') }}</h2> + <p>{{ $t('settings.import_mutes_from_a_csv_file') }}</p> + <Importer + :submit-handler="importMutes" + :success-message="$t('settings.mutes_imported')" + :error-message="$t('settings.mute_import_error')" + /> + </div> + <div class="setting-item"> + <h2>{{ $t('settings.mute_export') }}</h2> + <Exporter + :get-content="getMutesContent" + filename="mutes.csv" + :export-button-label="$t('settings.mute_export_button')" + /> + </div> </div> </template> diff --git a/src/components/settings_modal/tabs/filtering_tab.js b/src/components/settings_modal/tabs/filtering_tab.js index 3b2df556..5f38a5ae 100644 --- a/src/components/settings_modal/tabs/filtering_tab.js +++ b/src/components/settings_modal/tabs/filtering_tab.js @@ -2,6 +2,14 @@ import { filter, trim } from 'lodash' import Checkbox from 'src/components/checkbox/checkbox.vue' import SharedComputedObject from '../helpers/shared_computed_object.js' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faChevronDown +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faChevronDown +) const FilteringTab = { data () { diff --git a/src/components/settings_modal/tabs/filtering_tab.vue b/src/components/settings_modal/tabs/filtering_tab.vue index eea41514..813dc4cd 100644 --- a/src/components/settings_modal/tabs/filtering_tab.vue +++ b/src/components/settings_modal/tabs/filtering_tab.vue @@ -53,7 +53,10 @@ <option value="following">{{ $t('settings.reply_visibility_following') }}</option> <option value="self">{{ $t('settings.reply_visibility_self') }}</option> </select> - <i class="icon-down-open" /> + <FAIcon + class="select-down-icon" + icon="chevron-down" + /> </label> </div> <div> diff --git a/src/components/settings_modal/tabs/general_tab.js b/src/components/settings_modal/tabs/general_tab.js index 0eb37e44..df592a10 100644 --- a/src/components/settings_modal/tabs/general_tab.js +++ b/src/components/settings_modal/tabs/general_tab.js @@ -2,6 +2,16 @@ import Checkbox from 'src/components/checkbox/checkbox.vue' import InterfaceLanguageSwitcher from 'src/components/interface_language_switcher/interface_language_switcher.vue' import SharedComputedObject from '../helpers/shared_computed_object.js' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faChevronDown, + faGlobe +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faChevronDown, + faGlobe +) const GeneralTab = { data () { diff --git a/src/components/settings_modal/tabs/general_tab.vue b/src/components/settings_modal/tabs/general_tab.vue index 7f06d0bd..c1d0d0ec 100644 --- a/src/components/settings_modal/tabs/general_tab.vue +++ b/src/components/settings_modal/tabs/general_tab.vue @@ -58,6 +58,11 @@ {{ $t('settings.emoji_reactions_on_timeline') }} </Checkbox> </li> + <li> + <Checkbox v-model="virtualScrolling"> + {{ $t('settings.virtual_scrolling') }} + </Checkbox> + </li> </ul> </div> @@ -98,7 +103,10 @@ {{ subjectLineBehaviorDefaultValue == 'noop' ? $t('settings.instance_default_simple') : '' }} </option> </select> - <i class="icon-down-open" /> + <FAIcon + class="select-down-icon" + icon="chevron-down" + /> </label> </div> </li> @@ -122,7 +130,10 @@ {{ postContentTypeDefaultValue === postFormat ? $t('settings.instance_default_simple') : '' }} </option> </select> - <i class="icon-down-open" /> + <FAIcon + class="select-down-icon" + icon="chevron-down" + /> </label> </div> </li> @@ -217,7 +228,7 @@ v-if="!loopSilentAvailable" class="unavailable" > - <i class="icon-globe" />! {{ $t('settings.limited_availability') }} + <FAIcon icon="globe" />! {{ $t('settings.limited_availability') }} </div> </li> </ul> diff --git a/src/components/settings_modal/tabs/profile_tab.js b/src/components/settings_modal/tabs/profile_tab.js index bd6bef6a..a3e4feaf 100644 --- a/src/components/settings_modal/tabs/profile_tab.js +++ b/src/components/settings_modal/tabs/profile_tab.js @@ -8,6 +8,18 @@ import EmojiInput from 'src/components/emoji_input/emoji_input.vue' import suggestor from 'src/components/emoji_input/suggestor.js' import Autosuggest from 'src/components/autosuggest/autosuggest.vue' import Checkbox from 'src/components/checkbox/checkbox.vue' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faTimes, + faPlus, + faCircleNotch +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faTimes, + faPlus, + faCircleNotch +) const ProfileTab = { data () { diff --git a/src/components/settings_modal/tabs/profile_tab.scss b/src/components/settings_modal/tabs/profile_tab.scss index e14cf054..e821f952 100644 --- a/src/components/settings_modal/tabs/profile_tab.scss +++ b/src/components/settings_modal/tabs/profile_tab.scss @@ -119,10 +119,8 @@ &>.icon-container { width: 20px; - - &>.icon-cancel { - vertical-align: sub; - } + align-self: center; + margin: 0 .2em .5em; } } } diff --git a/src/components/settings_modal/tabs/profile_tab.vue b/src/components/settings_modal/tabs/profile_tab.vue index cf88c4e4..d62bc392 100644 --- a/src/components/settings_modal/tabs/profile_tab.vue +++ b/src/components/settings_modal/tabs/profile_tab.vue @@ -127,9 +127,9 @@ <div class="icon-container" > - <i + <FAIcon v-show="newFields.length > 1" - class="icon-cancel" + icon="times" @click="deleteField(i)" /> </div> @@ -139,7 +139,7 @@ class="add-field faint" @click="addField" > - <i class="icon-plus" /> + <FAIcon icon="plus" /> {{ $t("settings.profile_fields.add_field") }} </a> </div> @@ -166,10 +166,11 @@ :src="user.profile_image_url_original" class="current-avatar" > - <i + <FAIcon v-if="!isDefaultAvatar && pickAvatarBtnVisible" :title="$t('settings.reset_avatar')" - class="reset-button icon-cancel" + class="reset-button" + icon="times" type="button" @click="resetAvatar" /> @@ -194,10 +195,11 @@ <h2>{{ $t('settings.profile_banner') }}</h2> <div class="banner-background-preview"> <img :src="user.cover_photo"> - <i + <FAIcon v-if="!isDefaultBanner" :title="$t('settings.reset_profile_banner')" - class="reset-button icon-cancel" + class="reset-button" + icon="times" type="button" @click="resetBanner" /> @@ -214,9 +216,11 @@ @change="uploadFile('banner', $event)" > </div> - <i + <FAIcon v-if="bannerUploading" - class=" icon-spin4 animate-spin uploading" + class="uploading" + spin + icon="circle-notch" /> <button v-else-if="bannerPreview" @@ -230,8 +234,9 @@ class="alert error" > Error: {{ bannerUploadError }} - <i - class="button-icon icon-cancel" + <FAIcon + class="fa-scale-110 fa-old-padding" + icon="times" @click="clearUploadError('banner')" /> </div> @@ -240,10 +245,11 @@ <h2>{{ $t('settings.profile_background') }}</h2> <div class="banner-background-preview"> <img :src="user.background_image"> - <i + <FAIcon v-if="!isDefaultBackground" :title="$t('settings.reset_profile_background')" - class="reset-button icon-cancel" + class="reset-button" + icon="times" type="button" @click="resetBackground" /> @@ -260,9 +266,11 @@ @change="uploadFile('background', $event)" > </div> - <i + <FAIcon v-if="backgroundUploading" - class=" icon-spin4 animate-spin uploading" + class="uploading" + spin + icon="circle-notch" /> <button v-else-if="backgroundPreview" @@ -276,8 +284,10 @@ class="alert error" > Error: {{ backgroundUploadError }} - <i - class="button-icon icon-cancel" + <FAIcon + size="lg" + class="fa-scale-110 fa-old-padding" + icon="times" @click="clearUploadError('background')" /> </div> diff --git a/src/components/settings_modal/tabs/theme_tab/preview.vue b/src/components/settings_modal/tabs/theme_tab/preview.vue index 9d984659..02fea0b6 100644 --- a/src/components/settings_modal/tabs/theme_tab/preview.vue +++ b/src/components/settings_modal/tabs/theme_tab/preview.vue @@ -39,21 +39,29 @@ </i18n> <div class="icons"> - <i + <FAIcon + fixed-width style="color: var(--cBlue)" - class="button-icon icon-reply" + class="fa-scale-110 fa-old-padding" + icon="reply" /> - <i + <FAIcon + fixed-width style="color: var(--cGreen)" - class="button-icon icon-retweet" + class="fa-scale-110 fa-old-padding" + icon="retweet" /> - <i + <FAIcon + fixed-width style="color: var(--cOrange)" - class="button-icon icon-star" + class="fa-scale-110 fa-old-padding" + icon="star" /> - <i + <FAIcon + fixed-width style="color: var(--cRed)" - class="button-icon icon-cancel" + class="fa-scale-110 fa-old-padding" + icon="times" /> </div> </div> @@ -103,6 +111,25 @@ </div> </template> +<script> +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faTimes, + faStar, + faRetweet, + faReply +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faTimes, + faStar, + faRetweet, + faReply +) + +export default {} +</script> + <style lang="scss"> .preview-container { position: relative; 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 e3c5e80a..6cf75fe7 100644 --- a/src/components/settings_modal/tabs/theme_tab/theme_tab.js +++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.js @@ -35,6 +35,14 @@ import ExportImport from 'src/components/export_import/export_import.vue' import Checkbox from 'src/components/checkbox/checkbox.vue' import Preview from './preview.vue' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faChevronDown +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faChevronDown +) // List of color values used in v1 const v1OnlyNames = [ 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 5328c350..280e1955 100644 --- a/src/components/settings_modal/tabs/theme_tab/theme_tab.vue +++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.vue @@ -80,7 +80,10 @@ {{ style[0] || style.name }} </option> </select> - <i class="icon-down-open" /> + <FAIcon + class="select-down-icon" + icon="chevron-down" + /> </label> </div> </template> @@ -907,7 +910,10 @@ {{ $t('settings.style.shadows.components.' + shadow) }} </option> </select> - <i class="icon-down-open" /> + <FAIcon + class="select-down-icon" + icon="chevron-down" + /> </label> </div> <div class="override"> diff --git a/src/components/shadow_control/shadow_control.js b/src/components/shadow_control/shadow_control.js index f9e7b985..800c39d5 100644 --- a/src/components/shadow_control/shadow_control.js +++ b/src/components/shadow_control/shadow_control.js @@ -2,6 +2,20 @@ import ColorInput from '../color_input/color_input.vue' import OpacityInput from '../opacity_input/opacity_input.vue' import { getCssShadow } from '../../services/style_setter/style_setter.js' import { hex2rgb } from '../../services/color_convert/color_convert.js' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faTimes, + faChevronDown, + faChevronUp, + faPlus +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faChevronDown, + faChevronUp, + faTimes, + faPlus +) const toModel = (object = {}) => ({ x: 0, diff --git a/src/components/shadow_control/shadow_control.vue b/src/components/shadow_control/shadow_control.vue index 815a9e59..78f0e544 100644 --- a/src/components/shadow_control/shadow_control.vue +++ b/src/components/shadow_control/shadow_control.vue @@ -78,35 +78,50 @@ {{ $t('settings.style.shadows.shadow_id', { value: index }) }} </option> </select> - <i class="icon-down-open" /> + <FAIcon + icon="chevron-down" + class="select-down-icon" + /> </label> <button class="btn btn-default" :disabled="!ready || !present" @click="del" > - <i class="icon-cancel" /> + <FAIcon + fixed-width + icon="times" + /> </button> <button class="btn btn-default" :disabled="!moveUpValid" @click="moveUp" > - <i class="icon-up-open" /> + <FAIcon + fixed-width + icon="chevron-up" + /> </button> <button class="btn btn-default" :disabled="!moveDnValid" @click="moveDn" > - <i class="icon-down-open" /> + <FAIcon + fixed-width + icon="chevron-down" + /> </button> <button class="btn btn-default" :disabled="usingFallback" @click="add" > - <i class="icon-plus" /> + <FAIcon + fixed-width + icon="plus" + /> </button> </div> <div diff --git a/src/components/side_drawer/side_drawer.js b/src/components/side_drawer/side_drawer.js index 281052e5..fe736168 100644 --- a/src/components/side_drawer/side_drawer.js +++ b/src/components/side_drawer/side_drawer.js @@ -2,6 +2,34 @@ 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' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faSignInAlt, + faSignOutAlt, + faHome, + faComments, + faBell, + faUserPlus, + faBullhorn, + faSearch, + faTachometerAlt, + faCog, + faInfoCircle +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faSignInAlt, + faSignOutAlt, + faHome, + faComments, + faBell, + faUserPlus, + faBullhorn, + faSearch, + faTachometerAlt, + faCog, + faInfoCircle +) const SideDrawer = { props: [ 'logout' ], diff --git a/src/components/side_drawer/side_drawer.vue b/src/components/side_drawer/side_drawer.vue index eda5a68c..28c888fe 100644 --- a/src/components/side_drawer/side_drawer.vue +++ b/src/components/side_drawer/side_drawer.vue @@ -36,7 +36,11 @@ @click="toggleDrawer" > <router-link :to="{ name: 'login' }"> - <i class="button-icon icon-login" /> {{ $t("login.login") }} + <FAIcon + fixed-width + class="fa-scale-110 fa-old-padding" + icon="sign-in-alt" + /> {{ $t("login.login") }} </router-link> </li> <li @@ -44,7 +48,11 @@ @click="toggleDrawer" > <router-link :to="{ name: timelinesRoute }"> - <i class="button-icon icon-home-2" /> {{ $t("nav.timelines") }} + <FAIcon + fixed-width + class="fa-scale-110 fa-old-padding" + icon="home" + /> {{ $t("nav.timelines") }} </router-link> </li> <li @@ -55,10 +63,14 @@ :to="{ name: 'chats', params: { username: currentUser.screen_name } }" style="position: relative" > - <i class="button-icon icon-chat" /> {{ $t("nav.chats") }} + <FAIcon + fixed-width + class="fa-scale-110 fa-old-padding" + icon="comments" + /> {{ $t("nav.chats") }} <span v-if="unreadChatCount" - class="badge badge-notification unread-chat-count" + class="badge badge-notification" > {{ unreadChatCount }} </span> @@ -68,7 +80,11 @@ <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") }} + <FAIcon + fixed-width + class="fa-scale-110 fa-old-padding" + icon="bell" + /> {{ $t("nav.interactions") }} </router-link> </li> <li @@ -76,10 +92,14 @@ @click="toggleDrawer" > <router-link to="/friend-requests"> - <i class="button-icon icon-user-plus" /> {{ $t("nav.friend_requests") }} + <FAIcon + fixed-width + class="fa-scale-110 fa-old-padding" + icon="user-plus" + /> {{ $t("nav.friend_requests") }} <span v-if="followRequestCount > 0" - class="badge follow-request-count" + class="badge badge-notification" > {{ followRequestCount }} </span> @@ -90,7 +110,11 @@ @click="toggleDrawer" > <router-link :to="{ name: 'chat' }"> - <i class="button-icon icon-megaphone" /> {{ $t("shoutbox.title") }} + <FAIcon + fixed-width + class="fa-scale-110 fa-old-padding" + icon="bullhorn" + /> {{ $t("shoutbox.title") }} </router-link> </li> </ul> @@ -100,7 +124,11 @@ @click="toggleDrawer" > <router-link :to="{ name: 'search' }"> - <i class="button-icon icon-search" /> {{ $t("nav.search") }} + <FAIcon + fixed-width + class="fa-scale-110 fa-old-padding" + icon="search" + /> {{ $t("nav.search") }} </router-link> </li> <li @@ -108,7 +136,11 @@ @click="toggleDrawer" > <router-link :to="{ name: 'who-to-follow' }"> - <i class="button-icon icon-user-plus" /> {{ $t("nav.who_to_follow") }} + <FAIcon + fixed-width + class="fa-scale-110 fa-old-padding" + icon="user-plus" + /> {{ $t("nav.who_to_follow") }} </router-link> </li> <li @click="toggleDrawer"> @@ -116,12 +148,20 @@ href="#" @click="openSettingsModal" > - <i class="button-icon icon-cog" /> {{ $t("settings.settings") }} + <FAIcon + fixed-width + class="fa-scale-110 fa-old-padding" + icon="cog" + /> {{ $t("settings.settings") }} </a> </li> <li @click="toggleDrawer"> <router-link :to="{ name: 'about'}"> - <i class="button-icon icon-info-circled" /> {{ $t("nav.about") }} + <FAIcon + fixed-width + class="fa-scale-110 fa-old-padding" + icon="info-circle" + /> {{ $t("nav.about") }} </router-link> </li> <li @@ -132,7 +172,11 @@ href="/pleroma/admin/#/login-pleroma" target="_blank" > - <i class="button-icon icon-gauge" /> {{ $t("nav.administration") }} + <FAIcon + fixed-width + class="fa-scale-110 fa-old-padding" + icon="tachometer-alt" + /> {{ $t("nav.administration") }} </a> </li> <li @@ -143,7 +187,11 @@ href="#" @click="doLogout" > - <i class="button-icon icon-logout" /> {{ $t("login.logout") }} + <FAIcon + fixed-width + class="fa-scale-110 fa-old-padding" + icon="sign-out-alt" + /> {{ $t("login.logout") }} </a> </li> </ul> @@ -224,8 +272,10 @@ --lightText: var(--popoverLightText, $fallback--lightText); --icon: var(--popoverIcon, $fallback--icon); - .button-icon:before { - width: 1.1em; + .badge { + position: absolute; + right: 0.7rem; + top: 1em; } } @@ -272,7 +322,6 @@ border-bottom: 1px solid; border-color: $fallback--border; border-color: var(--border, $fallback--border); - margin: 0.2em 0; } .side-drawer ul:last-child { @@ -283,8 +332,11 @@ padding: 0; a { + box-sizing: border-box; display: block; - padding: 0.5em 0.85em; + height: 3em; + line-height: 3em; + padding: 0 0.7em; &:hover { background-color: $fallback--lightBg; diff --git a/src/components/status/status.js b/src/components/status/status.js index d263da68..142e1fc6 100644 --- a/src/components/status/status.js +++ b/src/components/status/status.js @@ -1,3 +1,4 @@ +import ReplyButton from '../reply_button/reply_button.vue' import FavoriteButton from '../favorite_button/favorite_button.vue' import ReactButton from '../react_button/react_button.vue' import RetweetButton from '../retweet_button/retweet_button.vue' @@ -15,11 +16,48 @@ import generateProfileLink from 'src/services/user_profile_link_generator/user_p import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js' import { muteWordHits } from '../../services/status_parser/status_parser.js' import { unescape, uniqBy } from 'lodash' -import { mapGetters, mapState } from 'vuex' + +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faEnvelope, + faLock, + faLockOpen, + faGlobe, + faTimes, + faRetweet, + faReply, + faExternalLinkSquareAlt, + faPlusSquare, + faSmileBeam, + faEllipsisH, + faStar, + faEyeSlash, + faEye, + faThumbtack +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faEnvelope, + faGlobe, + faLock, + faLockOpen, + faTimes, + faRetweet, + faReply, + faExternalLinkSquareAlt, + faPlusSquare, + faStar, + faSmileBeam, + faEllipsisH, + faEyeSlash, + faEye, + faThumbtack +) const Status = { name: 'Status', components: { + ReplyButton, FavoriteButton, ReactButton, RetweetButton, @@ -54,6 +92,8 @@ const Status = { replying: false, unmuted: false, userExpanded: false, + mediaPlaying: [], + suspendable: true, error: null } }, @@ -157,7 +197,7 @@ const Status = { return this.mergedConfig.hideFilteredStatuses }, hideStatus () { - return this.deleted || (this.muted && this.hideFilteredStatuses) + return (this.muted && this.hideFilteredStatuses) || this.virtualHidden }, isFocused () { // retweet or root of an expanded conversation @@ -207,23 +247,30 @@ const Status = { hidePostStats () { return this.mergedConfig.hidePostStats }, - ...mapGetters(['mergedConfig']), - ...mapState({ - betterShadow: state => state.interface.browserSupport.cssFilter, - currentUser: state => state.users.currentUser - }) + currentUser () { + return this.$store.state.users.currentUser + }, + betterShadow () { + return this.$store.state.interface.browserSupport.cssFilter + }, + mergedConfig () { + return this.$store.getters.mergedConfig + }, + isSuspendable () { + return !this.replying && this.mediaPlaying.length === 0 + } }, methods: { visibilityIcon (visibility) { switch (visibility) { case 'private': - return 'icon-lock' + return 'lock' case 'unlisted': - return 'icon-lock-open-alt' + return 'lock-open' case 'direct': - return 'icon-mail-alt' + return 'envelope' default: - return 'icon-globe' + return 'globe' } }, showError (error) { @@ -251,6 +298,12 @@ const Status = { }, generateUserProfileLink (id, name) { return generateProfileLink(id, name, this.$store.state.instance.restrictedNicknames) + }, + addMediaPlaying (id) { + this.mediaPlaying.push(id) + }, + removeMediaPlaying (id) { + this.mediaPlaying = this.mediaPlaying.filter(mediaId => mediaId !== id) } }, watch: { @@ -280,6 +333,9 @@ const Status = { if (this.isFocused && this.statusFromGlobalRepository.favoritedBy && this.statusFromGlobalRepository.favoritedBy.length !== num) { this.$store.dispatch('fetchFavs', this.status.id) } + }, + 'isSuspendable': function (val) { + this.suspendable = val } }, filters: { diff --git a/src/components/status/status.scss b/src/components/status/status.scss index 8d292d3f..0a94de32 100644 --- a/src/components/status/status.scss +++ b/src/components/status/status.scss @@ -7,8 +7,9 @@ $status-margin: 0.75em; min-width: 0; &:hover { - --still-image-img: visible; - --still-image-canvas: hidden; + --_still-image-img-visibility: visible; + --_still-image-canvas-visibility: hidden; + --_still-image-label-visibility: hidden; } &.-focused { @@ -25,6 +26,23 @@ $status-margin: 0.75em; --icon: var(--selectedPostIcon, $fallback--icon); } + &.-conversation { + border-left-width: 4px; + border-left-style: solid; + } + + .gravestone { + padding: $status-margin; + color: $fallback--faint; + color: var(--faint, $fallback--faint); + display: flex; + + .deleted-text { + margin: 0.5em 0; + align-items: center; + } + } + .status-container { display: flex; padding: $status-margin; @@ -41,6 +59,15 @@ $status-margin: 0.75em; justify-content: flex-end; } + ._misclick-prevention & { + pointer-events: none; + + .attachments { + pointer-events: initial; + cursor: initial; + } + } + .left-side { margin-right: $status-margin; } @@ -139,11 +166,6 @@ $status-margin: 0.75em; text-overflow: ellipsis; overflow-x: hidden; } - - .icon-reply { - // mirror the icon - transform: scaleX(-1); - } } & .reply-to-popover, @@ -183,7 +205,6 @@ $status-margin: 0.75em; } .reply-to { - display: flex; position: relative; } @@ -191,7 +212,6 @@ $status-margin: 0.75em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - margin-left: 0.2em; } .replies-separator { @@ -215,16 +235,10 @@ $status-margin: 0.75em; .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; + .repeat-icon { + color: $fallback--cGreen; + color: var(--cGreen, $fallback--cGreen); } } @@ -274,18 +288,6 @@ $status-margin: 0.75em; } } - .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; diff --git a/src/components/status/status.vue b/src/components/status/status.vue index 282ad37d..21412faa 100644 --- a/src/components/status/status.vue +++ b/src/components/status/status.vue @@ -10,17 +10,20 @@ class="alert error" > {{ error }} - <i - class="button-icon icon-cancel" + <span + class="fa-scale-110 fa-old-padding" @click="clearError" - /> + > + <FAIcon icon="times" /> + </span> </div> <template v-if="muted && !isPreview"> - <div class="status-csontainer muted"> + <div class="status-container muted"> <small class="status-username"> - <i + <FAIcon v-if="muted && retweet" - class="button-icon icon-retweet" + class="fa-scale-110 fa-old-padding repeat-icon" + icon="retweet" /> <router-link :to="userProfileLink"> {{ status.user.screen_name }} @@ -46,9 +49,14 @@ </small> <a href="#" - class="unmute" + class="unmute fa-scale-110 fa-old-padding" @click.prevent="toggleMute" - ><i class="button-icon icon-eye-off" /></a> + > + <FAIcon + icon="eye-slash" + class="fa-scale-110 fa-old-padding" + /> + </a> </div> </template> <template v-else> @@ -56,7 +64,10 @@ v-if="showPinned" class="pin" > - <i class="fa icon-pin faint" /> + <FAIcon + icon="thumbtack" + class="faint" + /> <span class="faint">{{ $t('status.pinned') }}</span> </div> <div @@ -86,8 +97,9 @@ :to="retweeterProfileLink" >{{ retweeter }}</router-link> </span> - <i - class="fa icon-retweet retweeted" + <FAIcon + icon="retweet" + class="repeat-icon" :title="$t('tool_tip.repeat')" /> {{ $t('timeline.repeated') }} @@ -95,6 +107,7 @@ </div> <div + v-if="!deleted" :class="[userClass, { highlighted: userStyle, '-repeat': retweet && !inConversation }]" :style="[ userStyle ]" class="status-container" @@ -166,15 +179,16 @@ :auto-update="60" /> </router-link> - <div + <span v-if="status.visibility" - class="button-icon visibility-icon" + class="visibility-icon" + :title="status.visibility | capitalize" > - <i - :class="visibilityIcon(status.visibility)" - :title="status.visibility | capitalize" + <FAIcon + class="fa-scale-110 fa-old-padding" + :icon="visibilityIcon(status.visibility)" /> - </div> + </span> <a v-if="!status.is_local && !isPreview" :href="status.external_url" @@ -182,22 +196,32 @@ class="source_url" title="Source" > - <i class="button-icon icon-link-ext-alt" /> + <FAIcon + class="fa-scale-110 fa-old-padding" + icon="external-link-square-alt" + /> + </a> + <a + v-if="expandable && !isPreview" + href="#" + title="Expand" + @click.prevent="toggleExpanded" + > + <FAIcon + class="fa-scale-110 fa-old-padding" + icon="plus-square" + /> </a> - <template v-if="expandable && !isPreview"> - <a - href="#" - title="Expand" - @click.prevent="toggleExpanded" - > - <i class="button-icon icon-plus-squared" /> - </a> - </template> <a v-if="unmuted" href="#" @click.prevent="toggleMute" - ><i class="button-icon icon-eye-off" /></a> + > + <FAIcon + icon="eye-slash" + class="fa-scale-110 fa-old-padding" + /> + </a> </span> </div> @@ -219,7 +243,11 @@ :aria-label="$t('tool_tip.reply')" @click.prevent="gotoOriginal(status.in_reply_to_status_id)" > - <i class="button-icon reply-button icon-reply" /> + <FAIcon + class="fa-scale-110 fa-old-padding" + icon="reply" + flip="horizontal" + /> <span class="faint-link reply-to-text" > @@ -227,6 +255,7 @@ </span> </a> </StatusPopover> + <span v-else class="reply-to-no-popover" @@ -272,6 +301,8 @@ :no-heading="noHeading" :highlight="highlight" :focused="isFocused" + @mediaplay="addMediaPlaying($event)" + @mediapause="removeMediaPlaying($event)" /> <transition name="fade"> @@ -320,21 +351,11 @@ v-if="!noHeading && !isPreview" class="status-actions" > - <div> - <i - v-if="loggedIn" - class="button-icon button-reply icon-reply" - :title="$t('tool_tip.reply')" - :class="{'-active': replying}" - @click.prevent="toggleReplying" - /> - <i - v-else - class="button-icon button-reply -disabled icon-reply" - :title="$t('tool_tip.reply')" - /> - <span v-if="status.replies_count > 0">{{ status.replies_count }}</span> - </div> + <reply-button + :replying="replying" + :status="status" + @toggle="toggleReplying" + /> <retweet-button :visibility="status.visibility" :logged-in="loggedIn" @@ -357,6 +378,25 @@ </div> </div> <div + v-else + class="gravestone" + > + <div class="left-side"> + <UserAvatar :compact="compact" /> + </div> + <div class="right-side"> + <div class="deleted-text"> + {{ $t('status.status_deleted') }} + </div> + <reply-button + v-if="replying" + :replying="replying" + :status="status" + @toggle="toggleReplying" + /> + </div> + </div> + <div v-if="replying" class="status-container reply-form" > @@ -376,4 +416,5 @@ </template> <script src="./status.js" ></script> + <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 df095de3..a6f79d76 100644 --- a/src/components/status_content/status_content.js +++ b/src/components/status_content/status_content.js @@ -7,6 +7,24 @@ import fileType from 'src/services/file_type/file_type.service' import { processHtml } from 'src/services/tiny_post_html_processor/tiny_post_html_processor.service.js' import { mentionMatchesUrl, extractTagFromUrl } from 'src/services/matcher/matcher.service.js' import { mapGetters, mapState } from 'vuex' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faCircleNotch, + faFile, + faMusic, + faImage, + faLink, + faPollH +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faCircleNotch, + faFile, + faMusic, + faImage, + faLink, + faPollH +) const StatusContent = { name: 'StatusContent', diff --git a/src/components/status_content/status_content.vue b/src/components/status_content/status_content.vue index 76fe3278..321cd477 100644 --- a/src/components/status_content/status_content.vue +++ b/src/components/status_content/status_content.vue @@ -55,29 +55,29 @@ @click.prevent="toggleShowMore" > {{ $t("status.show_content") }} - <span + <FAIcon v-if="attachmentTypes.includes('image')" - class="icon-picture" + icon="image" /> - <span + <FAIcon v-if="attachmentTypes.includes('video')" - class="icon-video" + icon="video" /> - <span + <FAIcon v-if="attachmentTypes.includes('audio')" - class="icon-music" + icon="music" /> - <span + <FAIcon v-if="attachmentTypes.includes('unknown')" - class="icon-doc" + icon="file" /> - <span + <FAIcon v-if="status.poll && status.poll.options" - class="icon-chart-bar" + icon="poll-h" /> - <span + <FAIcon v-if="status.card" - class="icon-link" + icon="link" /> </a> <a @@ -107,6 +107,8 @@ :attachment="attachment" :allow-play="true" :set-media="setMedia()" + @play="$emit('mediaplay', attachment.id)" + @pause="$emit('mediapause', attachment.id)" /> <gallery v-if="galleryAttachments.length > 0" @@ -180,6 +182,10 @@ $status-margin: 0.75em; text-align: center; display: inline-block; word-break: break-all; + + svg { + color: inherit; + } } img, video { diff --git a/src/components/status_popover/status_popover.js b/src/components/status_popover/status_popover.js index 51e7680c..c47f5631 100644 --- a/src/components/status_popover/status_popover.js +++ b/src/components/status_popover/status_popover.js @@ -1,4 +1,10 @@ import { find } from 'lodash' +import { library } from '@fortawesome/fontawesome-svg-core' +import { faCircleNotch } from '@fortawesome/free-solid-svg-icons' + +library.add( + faCircleNotch +) const StatusPopover = { name: 'StatusPopover', diff --git a/src/components/status_popover/status_popover.vue b/src/components/status_popover/status_popover.vue index 162eb210..8237ce00 100644 --- a/src/components/status_popover/status_popover.vue +++ b/src/components/status_popover/status_popover.vue @@ -27,7 +27,11 @@ v-else class="status-preview-no-content" > - <i class="icon-spin4 animate-spin" /> + <FAIcon + icon="circle-notch" + spin + size="2x" + /> </div> </div> </Popover> diff --git a/src/components/still-image/still-image.js b/src/components/still-image/still-image.js index ab40bbd7..8044e994 100644 --- a/src/components/still-image/still-image.js +++ b/src/components/still-image/still-image.js @@ -19,14 +19,16 @@ const StillImage = { }, methods: { onLoad () { - this.imageLoadHandler && this.imageLoadHandler(this.$refs.src) + const image = this.$refs.src + if (!image) return + this.imageLoadHandler && this.imageLoadHandler(image) const canvas = this.$refs.canvas if (!canvas) return - const width = this.$refs.src.naturalWidth - const height = this.$refs.src.naturalHeight + const width = image.naturalWidth + const height = image.naturalHeight canvas.width = width canvas.height = height - canvas.getContext('2d').drawImage(this.$refs.src, 0, 0, width, height) + canvas.getContext('2d').drawImage(image, 0, 0, width, height) }, onError () { this.imageLoadError && this.imageLoadError() diff --git a/src/components/still-image/still-image.vue b/src/components/still-image/still-image.vue index ad82210d..d3eb5925 100644 --- a/src/components/still-image/still-image.vue +++ b/src/components/still-image/still-image.vue @@ -42,7 +42,7 @@ width: 100%; height: 100%; object-fit: contain; - visibility: var(--still-image-canvas, visible); + visibility: var(--_still-image-canvas-visibility, visible); } img { @@ -66,16 +66,19 @@ border-radius: $fallback--tooltipRadius; border-radius: var(--tooltipRadius, $fallback--tooltipRadius); z-index: 2; - visibility: var(--still-image-label-visibility, visible); + visibility: var(--_still-image-label-visibility, visible); } &:hover canvas { display: none; } - &:hover::before, + &:hover::before { + visibility: var(--_still-image-label-visibility, hidden); + } + img { - visibility: var(--still-image-img, hidden); + visibility: var(--_still-image-img-visibility, hidden); } &:hover img { diff --git a/src/components/tab_switcher/tab_switcher.js b/src/components/tab_switcher/tab_switcher.js index 9c1da354..6e6e8193 100644 --- a/src/components/tab_switcher/tab_switcher.js +++ b/src/components/tab_switcher/tab_switcher.js @@ -1,5 +1,6 @@ import Vue from 'vue' import { mapState } from 'vuex' +import { FontAwesomeIcon as FAIcon } from '@fortawesome/vue-fontawesome' import './tab_switcher.scss' @@ -107,7 +108,7 @@ export default Vue.component('tab-switcher', { class={classesTab.join(' ')} type="button" > - {!slot.data.attrs.icon ? '' : (<i class={'tab-icon icon-' + slot.data.attrs.icon}/>)} + {!slot.data.attrs.icon ? '' : (<FAIcon class="tab-icon" size="2x" fixed-width icon={slot.data.attrs.icon}/>)} <span class="text"> {slot.data.attrs.label} </span> diff --git a/src/components/tab_switcher/tab_switcher.scss b/src/components/tab_switcher/tab_switcher.scss index d2ef4857..0ed614b7 100644 --- a/src/components/tab_switcher/tab_switcher.scss +++ b/src/components/tab_switcher/tab_switcher.scss @@ -4,7 +4,7 @@ display: flex; .tab-icon { - font-size: 2em; + margin: 0.2em auto; display: block; } @@ -91,7 +91,7 @@ flex-direction: column; @media all and (max-width: 800px) { - min-width: 1em; + min-width: 4em; } &:not(.active)::after { diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js index 5a7f7a78..cba46daf 100644 --- a/src/components/timeline/timeline.js +++ b/src/components/timeline/timeline.js @@ -2,7 +2,13 @@ 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' +import { debounce, throttle, keyBy } from 'lodash' +import { library } from '@fortawesome/fontawesome-svg-core' +import { faCircleNotch } from '@fortawesome/free-solid-svg-icons' + +library.add( + faCircleNotch +) export const getExcludedStatusIdsByPinning = (statuses, pinnedStatusIds) => { const ids = [] @@ -33,7 +39,9 @@ const Timeline = { return { paused: false, unfocused: false, - bottomedOut: false + bottomedOut: false, + virtualScrollIndex: 0, + blockingClicks: false } }, components: { @@ -63,8 +71,10 @@ const Timeline = { } }, classes () { + let rootClasses = !this.embedded ? ['panel', 'panel-default'] : [] + if (this.blockingClicks) rootClasses = rootClasses.concat(['-blocked', '_misclick-prevention']) return { - root: ['timeline'].concat(!this.embedded ? ['panel', 'panel-default'] : []), + root: rootClasses, header: ['timeline-heading'].concat(!this.embedded ? ['panel-heading'] : []), body: ['timeline-body'].concat(!this.embedded ? ['panel-body'] : []), footer: ['timeline-footer'].concat(!this.embedded ? ['panel-footer'] : []) @@ -78,6 +88,16 @@ const Timeline = { }, pinnedStatusIdsObject () { return keyBy(this.pinnedStatusIds) + }, + statusesToDisplay () { + const amount = this.timeline.visibleStatuses.length + const statusesPerSide = Math.ceil(Math.max(3, window.innerHeight / 80)) + const min = Math.max(0, this.virtualScrollIndex - statusesPerSide) + const max = Math.min(amount, this.virtualScrollIndex + statusesPerSide) + return this.timeline.visibleStatuses.slice(min, max).map(_ => _.id) + }, + virtualScrollingEnabled () { + return this.$store.getters.mergedConfig.virtualScrolling } }, created () { @@ -85,7 +105,7 @@ const Timeline = { const credentials = store.state.users.currentUser.credentials const showImmediately = this.timeline.visibleStatuses.length === 0 - window.addEventListener('scroll', this.scrollLoad) + window.addEventListener('scroll', this.handleScroll) if (store.state.api.fetchers[this.timelineName]) { return false } @@ -104,14 +124,24 @@ const Timeline = { this.unfocused = document.hidden } window.addEventListener('keydown', this.handleShortKey) + setTimeout(this.determineVisibleStatuses, 250) }, destroyed () { - window.removeEventListener('scroll', this.scrollLoad) + window.removeEventListener('scroll', this.handleScroll) window.removeEventListener('keydown', this.handleShortKey) if (typeof document.hidden !== 'undefined') document.removeEventListener('visibilitychange', this.handleVisibilityChange, false) this.$store.commit('setLoading', { timeline: this.timelineName, value: false }) }, methods: { + stopBlockingClicks: debounce(function () { + this.blockingClicks = false + }, 1000), + blockClicksTemporarily () { + if (!this.blockingClicks) { + this.blockingClicks = true + } + this.stopBlockingClicks() + }, handleShortKey (e) { // Ignore when input fields are focused if (['textarea', 'input'].includes(e.target.tagName.toLowerCase())) return @@ -123,6 +153,7 @@ const Timeline = { this.$store.commit('queueFlush', { timeline: this.timelineName, id: 0 }) this.fetchOlderStatuses() } else { + this.blockClicksTemporarily() this.$store.commit('showNewStatuses', { timeline: this.timelineName }) this.paused = false } @@ -146,6 +177,48 @@ const Timeline = { } }) }, 1000, this), + determineVisibleStatuses () { + if (!this.$refs.timeline) return + if (!this.virtualScrollingEnabled) return + + const statuses = this.$refs.timeline.children + const cappedScrollIndex = Math.max(0, Math.min(this.virtualScrollIndex, statuses.length - 1)) + + if (statuses.length === 0) return + + const height = Math.max(document.body.offsetHeight, window.pageYOffset) + + const centerOfScreen = window.pageYOffset + (window.innerHeight * 0.5) + + // Start from approximating the index of some visible status by using the + // the center of the screen on the timeline. + let approxIndex = Math.floor(statuses.length * (centerOfScreen / height)) + let err = statuses[approxIndex].getBoundingClientRect().y + + // if we have a previous scroll index that can be used, test if it's + // closer than the previous approximation, use it if so + + const virtualScrollIndexY = statuses[cappedScrollIndex].getBoundingClientRect().y + if (Math.abs(err) > virtualScrollIndexY) { + approxIndex = cappedScrollIndex + err = virtualScrollIndexY + } + + // if the status is too far from viewport, check the next/previous ones if + // they happen to be better + while (err < -20 && approxIndex < statuses.length - 1) { + err += statuses[approxIndex].offsetHeight + approxIndex++ + } + while (err > window.innerHeight + 100 && approxIndex > 0) { + approxIndex-- + err -= statuses[approxIndex].offsetHeight + } + + // this status is now the center point for virtual scrolling and visible + // statuses will be nearby statuses before and after it + this.virtualScrollIndex = approxIndex + }, scrollLoad (e) { const bodyBRect = document.body.getBoundingClientRect() const height = Math.max(bodyBRect.height, -(bodyBRect.y)) @@ -155,6 +228,10 @@ const Timeline = { this.fetchOlderStatuses() } }, + handleScroll: throttle(function (e) { + this.determineVisibleStatuses() + this.scrollLoad(e) + }, 200), handleVisibilityChange () { this.unfocused = document.hidden } diff --git a/src/components/timeline/timeline.vue b/src/components/timeline/timeline.vue index 2ff933e9..04859852 100644 --- a/src/components/timeline/timeline.vue +++ b/src/components/timeline/timeline.vue @@ -1,5 +1,5 @@ <template> - <div :class="[classes.root, 'timeline']"> + <div :class="[classes.root, 'Timeline']"> <div :class="classes.header"> <TimelineMenu v-if="!embedded" /> <div @@ -32,7 +32,10 @@ </div> </div> <div :class="classes.body"> - <div class="timeline"> + <div + ref="timeline" + class="timeline" + > <template v-for="statusId in pinnedStatusIds"> <conversation v-if="timeline.statusesObject[statusId]" @@ -54,6 +57,7 @@ :collapsable="true" :in-profile="inProfile" :profile-user-id="userId" + :virtual-hidden="virtualScrollingEnabled && !statusesToDisplay.includes(status.id)" /> </template> </div> @@ -88,7 +92,11 @@ v-else class="new-status-notification text-center panel-footer" > - <i class="icon-spin3 animate-spin" /> + <FAIcon + icon="circle-notch" + spin + size="lg" + /> </div> </div> </div> @@ -99,10 +107,14 @@ <style lang="scss"> @import '../../_variables.scss'; -.timeline { +.Timeline { .loadmore-text { opacity: 1; } + + &.-blocked { + cursor: progress; + } } .timeline-heading { diff --git a/src/components/timeline_menu/timeline_menu.js b/src/components/timeline_menu/timeline_menu.js index 2be75b06..4ccd52b4 100644 --- a/src/components/timeline_menu/timeline_menu.js +++ b/src/components/timeline_menu/timeline_menu.js @@ -1,5 +1,23 @@ import Popover from '../popover/popover.vue' import { mapState } from 'vuex' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faUsers, + faGlobe, + faBookmark, + faEnvelope, + faHome, + faChevronDown +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faUsers, + faGlobe, + faBookmark, + faEnvelope, + faHome, + faChevronDown +) // Route -> i18n key mapping, exported andnot in the computed // because nav panel benefits from the same information. diff --git a/src/components/timeline_menu/timeline_menu.vue b/src/components/timeline_menu/timeline_menu.vue index be512d60..c46531be 100644 --- a/src/components/timeline_menu/timeline_menu.vue +++ b/src/components/timeline_menu/timeline_menu.vue @@ -1,7 +1,7 @@ <template> <Popover trigger="click" - class="timeline-menu" + class="TimelineMenu" :class="{ 'open': isOpen }" :margin="{ left: -15, right: -200 }" :bound-to="{ x: 'container' }" @@ -16,27 +16,47 @@ <ul> <li v-if="currentUser"> <router-link :to="{ name: 'friends' }"> - <i class="button-icon icon-home-2" />{{ $t("nav.timeline") }} + <FAIcon + fixed-width + class="fa-scale-110 fa-old-padding " + icon="home" + />{{ $t("nav.timeline") }} </router-link> </li> <li v-if="currentUser"> <router-link :to="{ name: 'bookmarks'}"> - <i class="button-icon icon-bookmark" />{{ $t("nav.bookmarks") }} + <FAIcon + fixed-width + class="fa-scale-110 fa-old-padding " + 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") }} + <FAIcon + fixed-width + class="fa-scale-110 fa-old-padding " + icon="envelope" + />{{ $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") }} + <FAIcon + fixed-width + class="fa-scale-110 fa-old-padding " + 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") }} + <FAIcon + fixed-width + class="fa-scale-110 fa-old-padding " + icon="globe" + />{{ $t("nav.twkn") }} </router-link> </li> </ul> @@ -46,7 +66,10 @@ class="title timeline-menu-title" > <span>{{ timelineName() }}</span> - <i class="icon-down-open" /> + <FAIcon + size="sm" + icon="chevron-down" + /> </div> </Popover> </template> @@ -56,17 +79,19 @@ <style lang="scss"> @import '../../_variables.scss'; -.timeline-menu { +.TimelineMenu { 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; @@ -77,10 +102,12 @@ 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); } @@ -88,7 +115,6 @@ .timeline-menu-title { margin: 0; cursor: pointer; - display: flex; user-select: none; width: 100%; @@ -98,15 +124,13 @@ white-space: nowrap; } - i { + svg { margin-left: 0.6em; - flex-shrink: 0; - font-size: 1rem; transition: transform 100ms; } } - &.open .timeline-menu-title i { + &.open .timeline-menu-title svg { color: $fallback--text; color: var(--panelText, $fallback--text); transform: rotate(180deg); @@ -138,15 +162,11 @@ &:last-child { border: none; } - - i { - margin: 0 0.5em; - } } a { display: block; - padding: 0.6em 0; + padding: 0.6em 0.65em; &:hover { background-color: $fallback--lightBg; @@ -174,6 +194,11 @@ text-decoration: underline; } } + + svg { + margin-right: 0.4em; + margin-left: -0.2em; + } } } diff --git a/src/components/user_avatar/user_avatar.vue b/src/components/user_avatar/user_avatar.vue index e4e4127c..0f7c584b 100644 --- a/src/components/user_avatar/user_avatar.vue +++ b/src/components/user_avatar/user_avatar.vue @@ -1,5 +1,6 @@ <template> <StillImage + v-if="user" class="Avatar" :alt="user.screen_name" :title="user.screen_name" @@ -7,6 +8,11 @@ :class="{ 'avatar-compact': compact, 'better-shadow': betterShadow }" :image-load-error="imageLoadError" /> + <div + v-else + class="Avatar -placeholder" + :class="{ 'avatar-compact': compact }" + /> </template> <script src="./user_avatar.js"></script> @@ -14,11 +20,14 @@ @import '../../_variables.scss'; .Avatar { - --still-image-label-visibility: hidden; + --_avatarShadowBox: var(--avatarStatusShadow); + --_avatarShadowFilter: var(--avatarStatusShadowFilter); + --_avatarShadowInset: var(--avatarStatusShadowInset); + --_still-image-label-visibility: hidden; width: 48px; height: 48px; - box-shadow: var(--avatarStatusShadow); + box-shadow: var(--_avatarShadowBox); border-radius: $fallback--avatarRadius; border-radius: var(--avatarRadius, $fallback--avatarRadius); @@ -28,8 +37,8 @@ } &.better-shadow { - box-shadow: var(--avatarStatusShadowInset); - filter: var(--avatarStatusShadowFilter) + box-shadow: var(--_avatarShadowInset); + filter: var(--_avatarShadowFilter); } &.animated::before { @@ -42,5 +51,10 @@ border-radius: $fallback--avatarAltRadius; border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius); } + + &.-placeholder { + background-color: $fallback--fg; + background-color: var(--fg, $fallback--fg); + } } </style> diff --git a/src/components/user_card/user_card.js b/src/components/user_card/user_card.js index 8e6b9d7f..3a8efafc 100644 --- a/src/components/user_card/user_card.js +++ b/src/components/user_card/user_card.js @@ -6,6 +6,22 @@ import ModerationTools from '../moderation_tools/moderation_tools.vue' import AccountActions from '../account_actions/account_actions.vue' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' import { mapGetters } from 'vuex' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faBell, + faRss, + faChevronDown, + faSearchPlus, + faExternalLinkAlt +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faRss, + faBell, + faChevronDown, + faSearchPlus, + faExternalLinkAlt +) export default { props: [ diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue index 041bb80f..f916af9d 100644 --- a/src/components/user_card/user_card.vue +++ b/src/components/user_card/user_card.vue @@ -21,7 +21,10 @@ :user="user" /> <div class="user-info-avatar-link-overlay"> - <i class="button-icon icon-zoom-in" /> + <FAIcon + class="fa-scale-110 fa-old-padding" + icon="search-plus" + /> </div> </a> <router-link @@ -54,8 +57,12 @@ v-if="isOtherUser && !user.is_local" :href="user.statusnet_profile_url" target="_blank" + class="external-link-button" > - <i class="icon-link-ext usersettings" /> + <FAIcon + class="icon" + icon="external-link-alt" + /> </a> <AccountActions v-if="isOtherUser && loggedIn" @@ -85,7 +92,13 @@ bot </span> </template> - <span v-if="user.locked"><i class="icon icon-lock" /></span> + <span v-if="user.locked"> + <FAIcon + class="lock-icon" + icon="lock" + size="sm" + /> + </span> <span v-if="!mergedConfig.hideUserStats && !hideBio" class="dailyAvg" @@ -133,7 +146,10 @@ <option value="striped">Striped bg</option> <option value="side">Side stripe</option> </select> - <i class="icon-down-open" /> + <FAIcon + class="select-down-icon" + icon="chevron-down" + /> </label> </div> </div> @@ -150,7 +166,7 @@ :click="subscribeUser" :title="$t('user_card.subscribe')" > - <i class="icon-bell-alt" /> + <FAIcon icon="bell" /> </ProgressButton> <ProgressButton v-else @@ -158,7 +174,18 @@ :click="unsubscribeUser" :title="$t('user_card.unsubscribe')" > - <i class="icon-bell-ringing-o" /> + <FALayers> + <FAIcon + icon="rss" + transform="left-5 shrink-6 up-3 rotate-20" + flip="horizontal" + /> + <FAIcon + icon="rss" + transform="right-5 shrink-6 up-3 rotate-20" + /> + <FAIcon icon="bell" /> + </FALayers> </ProgressButton> </template> </div> @@ -255,6 +282,11 @@ .user-card { position: relative; + &:hover .Avatar { + --_still-image-img-visibility: visible; + --_still-image-canvas-visibility: hidden; + } + .panel-heading { padding: .5em 0; text-align: center; @@ -283,7 +315,7 @@ mask: linear-gradient(to top, white, transparent) bottom no-repeat, linear-gradient(to top, white, white); // Autoprefixed seem to ignore this one, and also syntax is different - -webkit-mask-composite: xor; + -webkit-mask-composite: xor; mask-composite: exclude; background-size: cover; mask-size: 100% 60%; @@ -355,20 +387,17 @@ max-height: 56px; .Avatar { + --_avatarShadowBox: var(--avatarShadow); + --_avatarShadowFilter: var(--avatarShadowFilter); + --_avatarShadowInset: var(--avatarShadowInset); + flex: 1 0 100%; width: 56px; height: 56px; - box-shadow: 0px 1px 8px rgba(0,0,0,0.75); - box-shadow: var(--avatarShadow); object-fit: cover; } } - &:hover .Avatar { - --still-image-img: visible; - --still-image-canvas: hidden; - } - &-avatar-link { position: relative; cursor: pointer; @@ -388,7 +417,7 @@ opacity: 0; transition: opacity .2s ease; - i { + svg { color: #FFF; } } @@ -398,10 +427,17 @@ } } - .usersettings { - color: $fallback--lightText; - color: var(--lightText, $fallback--lightText); - opacity: .8; + .external-link-button { + cursor: pointer; + width: 2.5em; + text-align: center; + margin: -0.5em 0; + padding: 0.5em 0; + + &:not(:hover) .icon { + color: $fallback--lightText; + color: var(--lightText, $fallback--lightText); + } } .user-summary { @@ -447,6 +483,10 @@ font-weight: light; font-size: 15px; + .lock-icon { + margin-left: 0.5em; + } + .user-screen-name { min-width: 1px; flex: 0 1 auto; @@ -508,7 +548,7 @@ padding-bottom: 0; flex: 1 0 auto; } - .userHighlightSel.select i { + .userHighlightSel.select svg { line-height: 22px; } diff --git a/src/components/user_list_popover/user_list_popover.js b/src/components/user_list_popover/user_list_popover.js index b60f2c4c..32ca2b8d 100644 --- a/src/components/user_list_popover/user_list_popover.js +++ b/src/components/user_list_popover/user_list_popover.js @@ -1,3 +1,9 @@ +import { library } from '@fortawesome/fontawesome-svg-core' +import { faCircleNotch } from '@fortawesome/free-solid-svg-icons' + +library.add( + faCircleNotch +) const UserListPopover = { name: 'UserListPopover', diff --git a/src/components/user_list_popover/user_list_popover.vue b/src/components/user_list_popover/user_list_popover.vue index 185c73ca..95673733 100644 --- a/src/components/user_list_popover/user_list_popover.vue +++ b/src/components/user_list_popover/user_list_popover.vue @@ -31,7 +31,11 @@ </div> </div> <div v-else> - <i class="icon-spin4 animate-spin" /> + <FAIcon + icon="circle-notch" + spin + size="3x" + /> </div> </div> </Popover> diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js index 201727d4..c0b55a6c 100644 --- a/src/components/user_profile/user_profile.js +++ b/src/components/user_profile/user_profile.js @@ -6,6 +6,14 @@ import Conversation from '../conversation/conversation.vue' import TabSwitcher from 'src/components/tab_switcher/tab_switcher.js' import List from '../list/list.vue' import withLoadMore from '../../hocs/with_load_more/with_load_more' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faCircleNotch +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faCircleNotch +) const FollowerList = withLoadMore({ fetch: (props, $store) => $store.dispatch('fetchFollowers', props.userId), diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue index c7c67c0a..f1f51840 100644 --- a/src/components/user_profile/user_profile.vue +++ b/src/components/user_profile/user_profile.vue @@ -122,9 +122,10 @@ </div> <div class="panel-body"> <span v-if="error">{{ error }}</span> - <i + <FAIcon v-else - class="icon-spin3 animate-spin" + spin + icon="circle-notch" /> </div> </div> @@ -142,6 +143,7 @@ .user-profile-fields { margin: 0 0.5em; + img { object-fit: contain; vertical-align: middle; @@ -156,8 +158,7 @@ .user-profile-field { display: flex; - margin: 0.25em auto; - max-width: 32em; + margin: 0.25em; border: 1px solid var(--border, $fallback--border); border-radius: $fallback--inputRadius; border-radius: var(--inputRadius, $fallback--inputRadius); diff --git a/src/components/video_attachment/video_attachment.js b/src/components/video_attachment/video_attachment.js index f0ca7e89..107b8985 100644 --- a/src/components/video_attachment/video_attachment.js +++ b/src/components/video_attachment/video_attachment.js @@ -3,27 +3,48 @@ const VideoAttachment = { props: ['attachment', 'controls'], data () { return { - loopVideo: this.$store.getters.mergedConfig.loopVideo + blocksSuspend: false, + // Start from true because removing "loop" property seems buggy in Vue + hasAudio: true + } + }, + computed: { + loopVideo () { + if (this.$store.getters.mergedConfig.loopVideoSilentOnly) { + return !this.hasAudio + } + return this.$store.getters.mergedConfig.loopVideo } }, methods: { - onVideoDataLoad (e) { + onPlaying (e) { + this.setHasAudio(e) + if (this.loopVideo) { + this.$emit('play', { looping: true }) + return + } + this.$emit('play') + }, + onPaused (e) { + this.$emit('pause') + }, + setHasAudio (e) { const target = e.srcElement || e.target + // If hasAudio is false, we've already marked this video to not have audio, + // a video can't gain audio out of nowhere so don't bother checking again. + if (!this.hasAudio) return if (typeof target.webkitAudioDecodedByteCount !== 'undefined') { // non-zero if video has audio track - if (target.webkitAudioDecodedByteCount > 0) { - this.loopVideo = this.loopVideo && !this.$store.getters.mergedConfig.loopVideoSilentOnly - } - } else if (typeof target.mozHasAudio !== 'undefined') { + if (target.webkitAudioDecodedByteCount > 0) return + } + if (typeof target.mozHasAudio !== 'undefined') { // true if video has audio track - if (target.mozHasAudio) { - this.loopVideo = this.loopVideo && !this.$store.getters.mergedConfig.loopVideoSilentOnly - } - } else if (typeof target.audioTracks !== 'undefined') { - if (target.audioTracks.length > 0) { - this.loopVideo = this.loopVideo && !this.$store.getters.mergedConfig.loopVideoSilentOnly - } + if (target.mozHasAudio) return + } + if (typeof target.audioTracks !== 'undefined') { + if (target.audioTracks.length > 0) return } + this.hasAudio = false } } } diff --git a/src/components/video_attachment/video_attachment.vue b/src/components/video_attachment/video_attachment.vue index 1ffed4e0..a4bf01e8 100644 --- a/src/components/video_attachment/video_attachment.vue +++ b/src/components/video_attachment/video_attachment.vue @@ -7,7 +7,8 @@ :alt="attachment.description" :title="attachment.description" playsinline - @loadeddata="onVideoDataLoad" + @playing="onPlaying" + @pause="onPaused" /> </template> diff --git a/src/hocs/with_load_more/with_load_more.js b/src/hocs/with_load_more/with_load_more.js index 6142f513..afb51a0f 100644 --- a/src/hocs/with_load_more/with_load_more.js +++ b/src/hocs/with_load_more/with_load_more.js @@ -3,6 +3,16 @@ import isEmpty from 'lodash/isEmpty' import { getComponentProps } from '../../services/component_utils/component_utils' import './with_load_more.scss' +import { FontAwesomeIcon as FAIcon } from '@fortawesome/vue-fontawesome' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faCircleNotch +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faCircleNotch +) + const withLoadMore = ({ fetch, // function to fetch entries and return a promise select, // function to select data from store @@ -82,7 +92,7 @@ const withLoadMore = ({ </WrappedComponent> <div class="with-load-more-footer"> {this.error && <a onClick={this.fetchEntries} class="alert error">{this.$t('general.generic_error')}</a>} - {!this.error && this.loading && <i class="icon-spin3 animate-spin"/>} + {!this.error && this.loading && <FAIcon spin icon="circle-notch"/>} {!this.error && !this.loading && !this.bottomedOut && <a onClick={this.fetchEntries}>{this.$t('general.more')}</a>} </div> </div> diff --git a/src/hocs/with_subscription/with_subscription.js b/src/hocs/with_subscription/with_subscription.js index 1775adcb..b1244276 100644 --- a/src/hocs/with_subscription/with_subscription.js +++ b/src/hocs/with_subscription/with_subscription.js @@ -3,6 +3,16 @@ import isEmpty from 'lodash/isEmpty' import { getComponentProps } from '../../services/component_utils/component_utils' import './with_subscription.scss' +import { FontAwesomeIcon as FAIcon } from '@fortawesome/vue-fontawesome' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faCircleNotch +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faCircleNotch +) + const withSubscription = ({ fetch, // function to fetch entries and return a promise select, // function to select data from store @@ -72,7 +82,7 @@ const withSubscription = ({ <div class="with-subscription-loading"> {this.error ? <a onClick={this.fetchData} class="alert error">{this.$t('general.generic_error')}</a> - : <i class="icon-spin3 animate-spin"/> + : <FAIcon spin icon="circle-notch"/> } </div> ) diff --git a/src/i18n/ca.json b/src/i18n/ca.json index f2bcdd06..b15b69f7 100644 --- a/src/i18n/ca.json +++ b/src/i18n/ca.json @@ -9,7 +9,8 @@ "scope_options": "Opcions d'abast i visibilitat", "text_limit": "Límit de text", "title": "Funcionalitats", - "who_to_follow": "A qui seguir" + "who_to_follow": "A qui seguir", + "pleroma_chat_messages": "Xat de Pleroma" }, "finder": { "error_fetching_user": "No s'ha pogut carregar l'usuari/a", @@ -17,7 +18,21 @@ }, "general": { "apply": "Aplica", - "submit": "Desa" + "submit": "Desa", + "close": "Tanca", + "verify": "Verifica", + "confirm": "Confirma", + "enable": "Habilita", + "disable": "Deshabilitar", + "cancel": "Cancel·la", + "show_less": "Mostra menys", + "show_more": "Mostra més", + "optional": "opcional", + "retry": "Prova de nou", + "error_retry": "Si us plau, prova de nou", + "generic_error": "Hi ha hagut un error", + "loading": "Carregant…", + "more": "Més" }, "login": { "login": "Inicia sessió", @@ -25,7 +40,12 @@ "password": "Contrasenya", "placeholder": "p.ex.: Maria", "register": "Registra't", - "username": "Nom d'usuari/a" + "username": "Nom d'usuari/a", + "recovery_code": "Codi de recuperació", + "enter_recovery_code": "Posa un codi de recuperació", + "authentication_code": "Codi d'autenticació", + "hint": "Entra per participar a la conversa", + "description": "Entra amb OAuth" }, "nav": { "chat": "Xat local públic", @@ -33,7 +53,16 @@ "mentions": "Mencions", "public_tl": "Flux públic del node", "timeline": "Flux personal", - "twkn": "Flux de la xarxa coneguda" + "twkn": "Flux de la xarxa coneguda", + "chats": "Xats", + "timelines": "Línies de temps", + "preferences": "Preferències", + "who_to_follow": "A qui seguir", + "search": "Cerca", + "dms": "Missatges directes", + "interactions": "Interaccions", + "back": "Enrere", + "administration": "Administració" }, "notifications": { "broken_favorite": "No es coneix aquest estat. S'està cercant.", @@ -42,14 +71,19 @@ "load_older": "Carrega més notificacions", "notifications": "Notificacions", "read": "Read!", - "repeated_you": "ha repetit el teu estat" + "repeated_you": "ha repetit el teu estat", + "migrated_to": "migrat a", + "no_more_notifications": "No més notificacions", + "follow_request": "et vol seguir" }, "post_status": { "account_not_locked_warning": "El teu compte no està {0}. Qualsevol persona pot seguir-te per llegir les teves entrades reservades només a seguidores.", "account_not_locked_warning_link": "bloquejat", "attachments_sensitive": "Marca l'adjunt com a delicat", "content_type": { - "text/plain": "Text pla" + "text/plain": "Text pla", + "text/markdown": "Markdown", + "text/html": "HTML" }, "content_warning": "Assumpte (opcional)", "default": "Em sento…", @@ -60,7 +94,13 @@ "private": "Només seguidors/es - Publica només per comptes que et segueixin", "public": "Pública - Publica als fluxos públics", "unlisted": "Silenciosa - No la mostris en fluxos públics" - } + }, + "scope_notice": { + "private": "Aquesta entrada serà visible només per a qui et segueixi", + "public": "Aquesta entrada serà visible per a tothom" + }, + "preview_empty": "Buida", + "preview": "Vista prèvia" }, "registration": { "bio": "Presentació", @@ -68,7 +108,17 @@ "fullname": "Nom per mostrar", "password_confirm": "Confirma la contrasenya", "registration": "Registra't", - "token": "Codi d'invitació" + "token": "Codi d'invitació", + "validations": { + "password_confirmation_match": "hauria de ser la mateixa que la contrasenya", + "password_confirmation_required": "no es pot deixar en blanc", + "password_required": "no es pot deixar en blanc", + "email_required": "no es pot deixar en blanc", + "fullname_required": "no es pot deixar en blanc", + "username_required": "no es pot deixar en blanc" + }, + "fullname_placeholder": "p. ex. Lain Iwakura", + "username_placeholder": "p. ex. lain" }, "settings": { "attachmentRadius": "Adjunts", @@ -94,7 +144,7 @@ "data_import_export_tab": "Importa o exporta dades", "default_vis": "Abast per defecte de les entrades", "delete_account": "Esborra el compte", - "delete_account_description": "Esborra permanentment el teu compte i tots els missatges", + "delete_account_description": "Esborra permanentment les teves dades i desactiva el teu compte.", "delete_account_error": "No s'ha pogut esborrar el compte. Si continua el problema, contacta amb l'administració del node.", "delete_account_instructions": "Confirma que vols esborrar el compte escrivint la teva contrasenya aquí sota.", "export_theme": "Desa el tema", @@ -164,7 +214,57 @@ "values": { "false": "no", "true": "sí" - } + }, + "show_moderator_badge": "Mostra una insígnia de Moderació en el meu perfil", + "show_admin_badge": "Mostra una insígnia d'Administració en el meu perfil", + "hide_followers_description": "No mostris qui m'està seguint", + "hide_follows_description": "No mostris a qui segueixo", + "notification_visibility_emoji_reactions": "Reaccions", + "new_email": "Nou correu electrònic", + "profile_fields": { + "value": "Contingut", + "name": "Etiqueta", + "add_field": "Afegeix un camp", + "label": "Metadades del perfil" + }, + "mutes_tab": "Silenciaments", + "interface": "Interfície", + "instance_default_simple": "(per defecte)", + "checkboxRadius": "Caselles", + "import_blocks_from_a_csv_file": "Importa bloquejos des d'un arxiu csv", + "hide_post_stats": "Amaga les estadístiques de les entrades (p. ex. el nombre de favorits)", + "use_one_click_nsfw": "Obre els adjunts NSFW amb només un clic", + "hide_muted_posts": "Amaga les entrades de comptes silenciats", + "avatar_size_instruction": "La mida mínima recomanada per la imatge de l'avatar és de 150x150 píxels.", + "domain_mutes": "Dominis", + "discoverable": "Permet la descoberta d'aquest compte en resultats de cerques i altres serveis", + "mutes_and_blocks": "Silenciaments i bloquejos", + "composing": "Composant", + "chatMessageRadius": "Missatge de xat", + "changed_email": "Correu electrònic canviat amb èxit!", + "change_email_error": "Hi ha hagut un problema al canviar el teu correu electrònic.", + "change_email": "Canvia el correu electrònic", + "bot": "Aquest és un compte automatitzat", + "blocks_tab": "Bloquejos", + "blocks_imported": "Bloquejos importats! Processar-los pot trigar una mica.", + "block_import_error": "Error al importar bloquejos", + "block_import": "Importa bloquejos", + "block_export_button": "Exporta els teus bloquejos a un arxiu csv", + "block_export": "Exporta bloquejos", + "allow_following_move": "Permet el seguiment automàtic quan un compte a qui seguim es mou", + "mfa": { + "scan": { + "secret_code": "Clau" + }, + "authentication_methods": "Mètodes d'autenticació", + "waiting_a_recovery_codes": "Rebent còpies de seguretat dels codis…", + "recovery_codes": "Codis de recuperació.", + "warning_of_generate_new_codes": "Quan generes nous codis de recuperació, els antics ja no funcionaran més.", + "generate_new_recovery_codes": "Genera nous codis de recuperació" + }, + "enter_current_password_to_confirm": "Posar la contrasenya actual per confirmar la teva identitat", + "security": "Seguretat", + "app_name": "Nom de l'aplicació" }, "time": { "day": "{0} dia", @@ -232,5 +332,75 @@ "who_to_follow": { "more": "More", "who_to_follow": "A qui seguir" + }, + "selectable_list": { + "select_all": "Selecciona-ho tot" + }, + "remote_user_resolver": { + "error": "No trobat.", + "searching_for": "Cercant per" + }, + "interactions": { + "load_older": "Carrega antigues interaccions", + "favs_repeats": "Repeticions i favorits" + }, + "emoji": { + "stickers": "Adhesius" + }, + "polls": { + "expired": "L'enquesta va acabar fa {0}", + "expires_in": "L'enquesta acaba en {0}", + "multiple_choices": "Múltiples opcions", + "single_choice": "Una sola opció", + "type": "Tipus d'enquesta", + "vote": "Vota", + "votes": "vots", + "option": "Opció", + "add_option": "Afegeix opció", + "add_poll": "Afegeix enquesta" + }, + "media_modal": { + "next": "Següent", + "previous": "Anterior" + }, + "importer": { + "error": "Ha succeït un error mentre s'importava aquest arxiu.", + "success": "Importat amb èxit." + }, + "image_cropper": { + "cancel": "Cancel·la", + "save_without_cropping": "Desa sense retallar", + "save": "Desa", + "crop_picture": "Retalla la imatge" + }, + "exporter": { + "processing": "Processant, aviat se't preguntarà per descarregar el teu arxiu", + "export": "Exporta" + }, + "domain_mute_card": { + "mute_progress": "Silenciant…", + "mute": "Silencia" + }, + "about": { + "staff": "Equip responsable", + "mrf": { + "simple": { + "quarantine_desc": "Aquesta instància només enviarà entrades públiques a les següents instàncies:", + "quarantine": "Quarantena", + "reject_desc": "Aquesta instància no acceptarà missatges de les següents instàncies:", + "reject": "Rebutja", + "accept_desc": "Aquesta instància només accepta missatges de les següents instàncies:", + "accept": "Accepta", + "simple_policies": "Polítiques específiques de la instància" + }, + "mrf_policies_desc": "Les polítiques MRF controlen el comportament federat de la instància. Les següents polítiques estan habilitades:", + "mrf_policies": "Polítiques MRF habilitades", + "keyword": { + "replace": "Reemplaça", + "reject": "Rebutja", + "keyword_policies": "Polítiques de paraules clau" + }, + "federation": "Federació" + } } } diff --git a/src/i18n/en.json b/src/i18n/en.json index 027e99be..d3d57562 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -275,6 +275,12 @@ "block_import": "Block import", "block_import_error": "Error importing blocks", "blocks_imported": "Blocks imported! Processing them will take a while.", + "mute_export": "Mute export", + "mute_export_button": "Export your mutes to a csv file", + "mute_import": "Mute import", + "mute_import_error": "Error importing mutes", + "mutes_imported": "Mutes imported! Processing them will take a while.", + "import_mutes_from_a_csv_file": "Import mutes from a csv file", "blocks_tab": "Blocks", "bot": "This is a bot account", "btnRadius": "Buttons", @@ -429,6 +435,7 @@ "false": "no", "true": "yes" }, + "virtual_scrolling": "Optimize timeline rendering", "fun": "Fun", "greentext": "Meme arrows", "notifications": "Notifications", @@ -658,7 +665,8 @@ "show_full_subject": "Show full subject", "hide_full_subject": "Hide full subject", "show_content": "Show content", - "hide_content": "Hide content" + "hide_content": "Hide content", + "status_deleted": "This post was deleted" }, "user_card": { "approve": "Approve", diff --git a/src/i18n/eo.json b/src/i18n/eo.json index e73ac2f8..1247d50d 100644 --- a/src/i18n/eo.json +++ b/src/i18n/eo.json @@ -5,7 +5,7 @@ "features_panel": { "chat": "Babilejo", "gopher": "Gopher", - "media_proxy": "Vidaŭdaĵa prokurilo", + "media_proxy": "Vidaŭdaĵa retperilo", "scope_options": "Agordoj de amplekso", "text_limit": "Limo de teksto", "title": "Funkcioj", @@ -33,7 +33,8 @@ "show_more": "Montri plion", "retry": "Reprovi", "error_retry": "Bonvolu reprovi", - "loading": "Enlegante…" + "loading": "Enlegante…", + "peek": "Antaŭmontri" }, "image_cropper": { "crop_picture": "Tondi bildon", @@ -70,9 +71,9 @@ "friend_requests": "Petoj pri abono", "mentions": "Mencioj", "dms": "Rektaj mesaĝoj", - "public_tl": "Publika tempolinio", - "timeline": "Tempolinio", - "twkn": "La tuta konata reto", + "public_tl": "Publika historio", + "timeline": "Historio", + "twkn": "Konata reto", "user_search": "Serĉi uzantojn", "who_to_follow": "Kiun aboni", "preferences": "Agordoj", @@ -80,7 +81,8 @@ "search": "Serĉi", "interactions": "Interagoj", "administration": "Administrado", - "bookmarks": "Legosignoj" + "bookmarks": "Legosignoj", + "timelines": "Historioj" }, "notifications": { "broken_favorite": "Nekonata stato, serĉante ĝin…", @@ -107,14 +109,14 @@ "text/html": "HTML" }, "content_warning": "Temo (malnepra)", - "default": "Ĵus alvenis al la Universala Kongreso!", + "default": "Ĵus alvenis Esperantujon!", "direct_warning": "Ĉi tiu afiŝo estos videbla nur por ĉiuj menciitaj uzantoj.", "posting": "Afiŝante", "scope": { "direct": "Rekta – Afiŝi nur al menciitaj uzantoj", "private": "Nur abonantoj – Afiŝi nur al abonantoj", - "public": "Publika – Afiŝi al publikaj tempolinioj", - "unlisted": "Nelistigita – Ne afiŝi al publikaj tempolinioj" + "public": "Publika – Afiŝi al publikaj historioj", + "unlisted": "Nelistigita – Ne afiŝi al publikaj historioj" }, "scope_notice": { "unlisted": "Ĉi tiu afiŝo ne estos videbla en la Publika historio kaj La tuta konata reto", @@ -193,7 +195,7 @@ "foreground": "Malfono", "general": "Ĝenerala", "hide_attachments_in_convo": "Kaŝi kunsendaĵojn en interparoloj", - "hide_attachments_in_tl": "Kaŝi kunsendaĵojn en tempolinio", + "hide_attachments_in_tl": "Kaŝi kunsendaĵojn en historioj", "max_thumbnails": "Maksimuma nombro da bildetoj en afiŝo", "hide_isp": "Kaŝi breton propran al nodo", "preload_images": "Antaŭ-enlegi bildojn", @@ -246,7 +248,7 @@ "profile_banner": "Rubando de profilo", "profile_tab": "Profilo", "radii_help": "Agordi fasadan rondigon de randoj (bildere)", - "replies_in_timeline": "Respondoj en tempolinio", + "replies_in_timeline": "Respondoj en historioj", "reply_visibility_all": "Montri ĉiujn respondojn", "reply_visibility_following": "Montri nur respondojn por mi aŭ miaj abonatoj", "reply_visibility_self": "Montri nur respondojn por mi", @@ -297,7 +299,12 @@ "older_version_imported": "La enportita dosiero estis farita per pli malnova versio de PleromaFE.", "future_version_imported": "La enportita dosiero estis farita per pli nova versio de PleromaFE.", "v2_imported": "La dosiero, kiun vi enportis, estis farita por malnova versio de PleromaFE. Ni provas maksimumigi interkonformecon, sed tamen eble montriĝos misoj.", - "upgraded_from_v2": "PleromaFE estis ĝisdatigita; la haŭto eble aspektos malsame ol kiel vi ĝin memoras." + "upgraded_from_v2": "PleromaFE estis ĝisdatigita; la haŭto eble aspektos malsame ol kiel vi ĝin memoras.", + "snapshot_missing": "Neniu momentokopio de haŭto estis en la dosiero, ĝi povas aspekti iom malsame ol oni intencis.", + "snapshot_present": "Ĉiuj valoroj estas transpasataj, ĉar momentokopio de haŭto estas enlegita. Vi povas enlegi anstataŭe la aktualajn datumojn de haŭto.", + "snapshot_source_mismatch": "Versioj konfliktas: plej probable la fasado estis reirigita kaj ree ĝisdatigita; se vi ŝanĝis la haŭton per pli malnova versio de la fasado, vi probable volas uzi la malnovan version. Alie uzu la novan.", + "migration_napshot_gone": "Ial mankis momentokopio; io povus aspekti malsame ol en via memoro.", + "migration_snapshot_ok": "Certige, momentokopio de la haŭto enlegiĝis. Vi povas provi enlegi datumojn de la haŭto." }, "use_source": "Nova versio", "use_snapshot": "Malnova versio", @@ -352,10 +359,11 @@ "icons": "Bildsimboloj", "poll": "Grafo de enketo", "underlay": "Subtavolo", - "popover": "Ŝpruchelpiloj, menuoj", + "popover": "Ŝprucaĵoj, menuoj", "post": "Afiŝoj/Priskriboj de uzantoj", "alert_neutral": "Neŭtrala", - "alert_warning": "Averto" + "alert_warning": "Averto", + "toggled": "Ŝaltita" }, "radii": { "_tab_label": "Rondeco" @@ -388,7 +396,8 @@ "buttonPressed": "Butono (premita)", "buttonPressedHover": "Butono (premita kaj je ŝvebo)", "input": "Eniga kampo" - } + }, + "hintV3": "Kolorojn de ombroj vi ankaŭ povas skribi per la sistemo {0}." }, "fonts": { "_tab_label": "Tiparoj", @@ -411,7 +420,7 @@ "button": "Butono", "text": "Kelko da pliaj {0} kaj {1}", "mono": "enhavo", - "input": "Ĵus alvenis al la Universala Kongreso!", + "input": "Ĵus alvenis Esperantujon!", "faint_link": "helpan manlibron", "fine_print": "Legu nian {0} por nenion utilan ekscii!", "header_faint": "Tio estas en ordo", @@ -420,7 +429,7 @@ } }, "discoverable": "Permesi trovon de ĉi tiu konto en serĉrezultoj kaj aliaj servoj", - "mutes_and_blocks": "Silentigitoj kaj blokitoj", + "mutes_and_blocks": "Blokado kaj silentigoj", "chatMessageRadius": "Babileja mesaĝo", "changed_email": "Retpoŝtadreso sukcese ŝanĝiĝis!", "change_email_error": "Eraris ŝanĝo de via retpoŝtadreso.", @@ -448,7 +457,10 @@ "warning_of_generate_new_codes": "Kiam vi estigos novajn rehavajn kodojn, viaj malnovaj ne plu funkcios.", "generate_new_recovery_codes": "Estigi novajn rehavajn kodojn", "title": "Duobla aŭtentikigo", - "otp": "OTP" + "otp": "OTP", + "wait_pre_setup_otp": "antaŭagordante OTP", + "setup_otp": "Agordi OTP", + "confirm_and_enable": "Konfirmi kaj ŝalti OTP" }, "enter_current_password_to_confirm": "Enigu vian pasvorton por konfirmi vian identecon", "security": "Sekureco", @@ -480,11 +492,11 @@ }, "import_blocks_from_a_csv_file": "Enporti blokitojn el CSV-dosiero", "hide_muted_posts": "Kaŝi afiŝojn de silentigitaj uzantoj", - "emoji_reactions_on_timeline": "Montri bildosignajn reagojn en la tempolinio", + "emoji_reactions_on_timeline": "Montri bildosignajn reagojn en historioj", "pad_emoji": "Meti spacetojn ĉirkaŭ bildosigno post ties elekto", "domain_mutes": "Retnomoj", "notification_blocks": "Blokinte uzanton vi malabonos ĝin kaj haltigos ĉiujn sciigojn.", - "notification_mutes": "Por ne plu ricevi sciigojn de certa uzanto, silentigu.", + "notification_mutes": "Por ne plu ricevi sciigojn de certa uzanto, silentigu ĝin.", "notification_setting_hide_notification_contents": "Kaŝi la sendinton kaj la enhavojn de pasivaj sciigoj", "notification_setting_privacy": "Privateco", "notification_setting_block_from_strangers": "Bloki sciigojn de uzantoj, kiujn vi ne abonas", @@ -495,7 +507,14 @@ "backend_version": "Versio de internaĵo", "title": "Versio" }, - "accent": "Emfazo" + "accent": "Emfazo", + "virtual_scrolling": "Optimumigi bildigon de historioj", + "import_mutes_from_a_csv_file": "Enporti silentigojn el CSV-dosiero", + "mutes_imported": "Silentigoj enportiĝis! Traktado daŭros iom da tempo.", + "mute_import_error": "Eraris enporto de silentigoj", + "mute_import": "Enporto de silentigoj", + "mute_export_button": "Elportu viajn silentigojn al CSV-dosiero", + "mute_export": "Elporto de silentigoj" }, "timeline": { "collapse": "Maletendi", @@ -503,7 +522,7 @@ "error_fetching": "Eraris ĝisdatigo", "load_older": "Montri pli malnovajn statojn", "no_retweet_hint": "Afiŝo estas markita kiel rekta aŭ nur por abonantoj, kaj ne eblas ĝin ripeti", - "repeated": "ripetita", + "repeated": "ripetis", "show_new": "Montri novajn", "up_to_date": "Ĝisdata", "no_more_statuses": "Neniuj pliaj statoj", @@ -648,21 +667,22 @@ "media_nsfw": "Devige marki vidaŭdaĵojn konsternaj", "media_removal_desc": "Ĉi tiu nodo forigas vidaŭdaĵojn de afiŝoj el la jenaj nodoj:", "media_removal": "Forigo de vidaŭdaĵoj", - "ftl_removal": "Forigo de la historio de «La tuta konata reto»", + "ftl_removal": "Forigo el la historio de «La tuta konata reto»", "quarantine_desc": "Ĉi tiu nodo sendos nur publikajn afiŝojn al la jenaj nodoj:", "quarantine": "Kvaranteno", "reject_desc": "Ĉi tiu nodo ne akceptos mesaĝojn de la jenaj nodoj:", "reject": "Rifuzi", "accept_desc": "Ĉi tiu nodo nur akceptas mesaĝojn de la jenaj nodoj:", "accept": "Akcepti", - "simple_policies": "Specialaj politikoj de la nodo" + "simple_policies": "Specialaj politikoj de la nodo", + "ftl_removal_desc": "Ĉi tiu nodo forigas la jenajn nodojn el la historio de «La tuta konata reto»:" }, "mrf_policies": "Ŝaltis politikon de Mesaĝa ŝanĝilaro (MRF)", "keyword": { "is_replaced_by": "→", "replace": "Anstataŭigi", "reject": "Rifuzi", - "ftl_removal": "Forigo de la historio de «La tuta konata reto»", + "ftl_removal": "Forigo el la historio de «La tuta konata reto»", "keyword_policies": "Politiko pri ŝlosilvortoj" }, "federation": "Federado", @@ -707,7 +727,8 @@ "pin": "Fiksi al profilo", "delete": "Forigi staton", "repeats": "Ripetoj", - "favorites": "Ŝatataj" + "favorites": "Ŝatoj", + "status_deleted": "Ĉi tiu afiŝo foriĝis" }, "time": { "years_short": "{0}j", @@ -769,7 +790,8 @@ "new": "Nova babilo", "chats": "Babiloj", "delete": "Forigi", - "you": "Vi:" + "you": "Vi:", + "message_user": "Mesaĝi al {nickname}" }, "password_reset": { "password_reset_required_but_mailer_is_disabled": "Vi devas restarigi vian pasvorton, sed restarigado de pasvortoj estas malŝaltita. Bonvolu kontakti la administranton de via nodo.", @@ -791,5 +813,8 @@ "additional_comments": "Aldonaj komentoj", "add_comment_description": "Ĉi tiu raporto sendiĝos al reguligistoj de via nodo. Vi povas komprenigi kial vi raportas ĉi tiun konton sube:", "title": "Raportante {0}" + }, + "shoutbox": { + "title": "Kriujo" } } diff --git a/src/i18n/es.json b/src/i18n/es.json index 718d9040..6889df9a 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -551,7 +551,14 @@ "change_email_error": "Ha ocurrido un error al intentar modificar tu correo electrónico.", "change_email": "Modificar el correo electrónico", "bot": "Esta cuenta es un bot", - "allow_following_move": "Permitir el seguimiento automático, cuando la cuenta que sigues se traslada a otra instancia" + "allow_following_move": "Permitir el seguimiento automático, cuando la cuenta que sigues se traslada a otra instancia", + "virtual_scrolling": "Optimizar la representación de la linea temporal", + "import_mutes_from_a_csv_file": "Importar silenciados desde un archivo csv", + "mutes_imported": "¡Silenciados importados! Procesarlos llevará un tiempo.", + "mute_import_error": "Error al importar los silenciados", + "mute_import": "Importar silenciados", + "mute_export_button": "Exportar los silenciados a un archivo csv", + "mute_export": "Exportar silenciados" }, "time": { "day": "{0} día", @@ -762,7 +769,7 @@ "ftl_removal_desc": "Esta instancia elimina las siguientes instancias de la línea de tiempo \"Toda la red conocida\":", "ftl_removal": "Eliminar de la línea de tiempo \"Toda La Red Conocida\"", "quarantine_desc": "Esta instancia enviará solo publicaciones públicas a las siguientes instancias:", - "simple_policies": "Políticas sobre instancias específicas", + "simple_policies": "Políticas específicas de la instancia", "reject_desc": "Esta instancia no aceptará mensajes de las siguientes instancias:", "reject": "Rechazar", "accept": "Aceptar" diff --git a/src/i18n/eu.json b/src/i18n/eu.json index fdca6b95..a45b7cfd 100644 --- a/src/i18n/eu.json +++ b/src/i18n/eu.json @@ -13,7 +13,8 @@ "scope_options": "Ikusgaitasun aukerak", "text_limit": "Testu limitea", "title": "Ezaugarriak", - "who_to_follow": "Nori jarraitu" + "who_to_follow": "Nori jarraitu", + "pleroma_chat_messages": "Pleroma Txata" }, "finder": { "error_fetching_user": "Errorea erabiltzailea eskuratzen", @@ -31,7 +32,13 @@ "disable": "Ezgaitu", "enable": "Gaitu", "confirm": "Baieztatu", - "verify": "Egiaztatu" + "verify": "Egiaztatu", + "peek": "Begiratu", + "close": "Itxi", + "dismiss": "Baztertu", + "retry": "Saiatu berriro", + "error_retry": "Saiatu berriro mesedez", + "loading": "Kargatzen…" }, "image_cropper": { "crop_picture": "Moztu argazkia", @@ -81,7 +88,10 @@ "user_search": "Erabiltzailea Bilatu", "search": "Bilatu", "who_to_follow": "Nori jarraitu", - "preferences": "Hobespenak" + "preferences": "Hobespenak", + "chats": "Txatak", + "timelines": "Denbora-lerroak", + "bookmarks": "Laster-markak" }, "notifications": { "broken_favorite": "Egoera ezezaguna, bilatzen…", @@ -91,7 +101,10 @@ "notifications": "Jakinarazpenak", "read": "Irakurrita!", "repeated_you": "zure mezua errepikatu du", - "no_more_notifications": "Ez dago jakinarazpen gehiago" + "no_more_notifications": "Ez dago jakinarazpen gehiago", + "reacted_with": "{0}kin erreakzionatu zuen", + "migrated_to": "hona migratua:", + "follow_request": "jarraitu nahi zaitu" }, "polls": { "add_poll": "Inkesta gehitu", @@ -114,7 +127,8 @@ "search_emoji": "Bilatu emoji bat", "add_emoji": "Emoji bat gehitu", "custom": "Ohiko emojiak", - "unicode": "Unicode emojiak" + "unicode": "Unicode emojiak", + "load_all": "{emojiAmount} emoji guztiak kargatzen" }, "stickers": { "add_sticker": "Pegatina gehitu" @@ -226,7 +240,7 @@ "composing": "Idazten", "confirm_new_password": "Baieztatu pasahitz berria", "current_avatar": "Zure uneko avatarra", - "current_password": "Indarrean den pasahitza", + "current_password": "Indarrean dagoen pasahitza", "current_profile_banner": "Zure profilaren banner-a", "data_import_export_tab": "Datuak Inportatu / Esportatu", "default_vis": "Lehenetsitako ikusgaitasunak", @@ -634,9 +648,40 @@ "about": { "mrf": { "keyword": { - "keyword_policies": "Gako-hitz politika" + "keyword_policies": "Gako-hitz politika", + "ftl_removal": "\"Ezagutzen den Sarea\" denbora-lerrotik ezabatu", + "is_replaced_by": "→", + "replace": "Ordezkatuak", + "reject": "Ukatuak" }, - "federation": "Federazioa" - } + "federation": "Federazioa", + "simple": { + "media_nsfw_desc": "Instantzia honek hurrengo instantzien multimediak sentikorrak izatera behartzen ditu:", + "media_nsfw": "Behartu Multimedia Sentikor", + "media_removal_desc": "Instantzia honek atxikitutako multimedia hurrengo instantzietatik ezabatzen ditu:", + "media_removal": "Multimedia Ezabatu", + "ftl_removal_desc": "Instantzia honek hurrengo instantziak ezabatzen ditu \"Ezagutzen den Sarea\" denbora-lerrotik:", + "ftl_removal": "\"Ezagutzen den Sarea\" denbora-lerrotik ezabatu", + "quarantine_desc": "Instantzia honek soilik mezu publikoak bidaliko ditu instantzia hauetara:", + "quarantine": "Koarentena", + "reject_desc": "Instantzia honek ez ditu hurrengo instantzien mezuak onartuko:", + "reject": "Ukatuak", + "accept_desc": "Instantzia honek hurrengo instantzietako mezuak soilik onartzen ditu:", + "accept": "Onartu", + "simple_policies": "Gure instantziaren politika zehatzak" + }, + "mrf_policies_desc": "MRF politikek instantzia honen federazioa manipulatzen dute gainerako instantziekin. Honako politika hauek daude gaituta:", + "mrf_policies": "Gaitutako MRF politikak" + }, + "staff": "Arduradunak" + }, + "domain_mute_card": { + "unmute_progress": "Isiltasuna kentzen…", + "unmute": "Isiltasuna kendu", + "mute_progress": "Isiltzen…", + "mute": "Isilarazi" + }, + "shoutbox": { + "title": "Oihu-kutxa" } } diff --git a/src/i18n/fa.json b/src/i18n/fa.json new file mode 100644 index 00000000..0e8bda4b --- /dev/null +++ b/src/i18n/fa.json @@ -0,0 +1,155 @@ +{ + "about": { + "mrf": { + "simple": { + "media_removal_desc": "این نمونه رسانهی پیغامهای نمونههای ذکر شده را حذف میکند:", + "ftl_removal_desc": "این نمونه، نمونههای ذکر شده را از تایملاین «تمام شبکه شناخته شده» حذف میکند:", + "media_removal": "حذف رسانه", + "ftl_removal": "حذف از تایملاین «تمام شبکه شناخته شده»", + "quarantine_desc": "این نمونه تنها پیغامهای عمومی را به نمونههای ذکر شده پیغام ارسال میکند:", + "quarantine": "قرنطینه شده", + "reject_desc": "این نمونه از نمونههای ذکر شده پیغامی دریافت نمیکند:", + "reject": "رد کننده", + "accept_desc": "این نمونه تنها از نمونههای ذکر شده پیغام دریافت میکند:", + "simple_policies": "سیاستهای مخصوص نمونه", + "accept": "دریافت کننده", + "media_nsfw_desc": "این نمونه، رسانه نمونههای ذکر شده را به اجبار حساس میکند:", + "media_nsfw": "به اجبار حساس کردن رسانه" + }, + "federation": "فدراسیون", + "mrf_policies_desc": "سیاستهای MRF رفتار فدراسیون این نمونه را تغییر میدهد. سیاستهایی که در ادامه آمده اعمال شده است:", + "keyword": { + "reject": "رد کننده", + "replace": "جایگزین کننده", + "keyword_policies": "سیاستهای واژگان کلیدی", + "is_replaced_by": "→", + "ftl_removal": "حذف از تایملاین «تمام شبکه شناخته شده»" + }, + "mrf_policies": "سیاستهای MRF(وسیله بازنویسی پیغام) فعال شده" + }, + "staff": "کارکنان" + }, + "image_cropper": { + "crop_picture": "برش تصویر", + "cancel": "لغو", + "save_without_cropping": "ذخیره بدون برش", + "save": "ذخیره" + }, + "notifications": { + "followed_you": "پیگیر شما شد", + "favorited_you": "پیغام شما را پسندید", + "broken_favorite": "پیغام ناشناخته، در حال جستجو…" + }, + "nav": { + "chats": "گپها", + "timelines": "تایملاینها", + "preferences": "ترجیحات", + "who_to_follow": "چه کسانی را پیگیری کنیم", + "search": "جستجو", + "user_search": "جستجوی کاربر", + "bookmarks": "نشانکها", + "twkn": "شبکه شناخته شده", + "timeline": "تایملاین", + "public_tl": "تایملاین عمومی", + "dms": "پیغامهای مستقیم", + "interactions": "تعاملات", + "mentions": "نام بردنها", + "friend_requests": "درخواست پیگیری", + "back": "قبلی", + "administration": "مدیریت", + "about": "درباره" + }, + "features_panel": { + "who_to_follow": "چه کسانی را پیگیری کنیم", + "title": "ویژگیها", + "text_limit": "محدودیت متن", + "scope_options": "تنظیمات حوزه", + "media_proxy": "پروکسی رسانه", + "gopher": "گوفر", + "pleroma_chat_messages": "گپ پلروما", + "chat": "گپ" + }, + "media_modal": { + "next": "بعدی", + "previous": "قبلی" + }, + "login": { + "heading": { + "recovery": "بازیابی دو مرحلهای", + "totp": "احراز هویت دو مرحلهای" + }, + "enter_two_factor_code": "کد احراز هویت دو مرحلهای را وارد کنید", + "recovery_code": "کد بازیابی", + "enter_recovery_code": "کد بازیابی را وارد کنید", + "authentication_code": "کد احراز هویت", + "hint": "برای شرکت در گفتگو، وارد سامانه شوید", + "username": "نام کاربری", + "register": "ثبت نام", + "description": "ورود به سامانه از طریق OAuth", + "placeholder": "به عنوان مثال: lain", + "password": "رمز عبور", + "logout": "خروج از سامانه", + "login": "ورود به سامانه" + }, + "importer": { + "error": "در حین بارگذاری فایل خطایی رخ داد.", + "success": "با موفقیت بارگذاری شد.", + "submit": "ارسال" + }, + "general": { + "peek": "نگاه سریع", + "close": "بستن", + "verify": "تأیید", + "confirm": "تأیید", + "enable": "فعال", + "disable": "غیر فعال", + "cancel": "لغو", + "show_less": "کمتر نشان بده", + "show_more": "بیشتر نشان بده", + "optional": "اختیاری", + "retry": "دوباره امتحان کنید", + "error_retry": "لطفاً دوباره امتحان کنید", + "generic_error": "خطایی رخ داد", + "loading": "در حال بارگذاری…", + "more": "بیشتر", + "submit": "ارسال", + "apply": "اعمال" + }, + "finder": { + "find_user": "جستجوی کاربر", + "error_fetching_user": "دریافت کاربر با خطا مواجه شد" + }, + "exporter": { + "processing": "در حال پردازش، شما به زودی قادر به دانلود فایل خواهید بود", + "export": "صادر کردن" + }, + "domain_mute_card": { + "unmute": "صدا دار", + "unmute_progress": "در حال صدا دار کردن …", + "mute_progress": "در حال بی صدا کردن…", + "mute": "بی صدا" + }, + "shoutbox": { + "title": "چت باکس" + }, + "display_date": { + "today": "امروز" + }, + "file_type": { + "file": "فایل", + "image": "تصویر", + "video": "ویدئو", + "audio": "صدا" + }, + "chats": { + "empty_chat_list_placeholder": "شما هنوز هیچ گپی ندارید، گپ جدیدی را آغاز کنید!", + "delete": "حذف", + "error_sending_message": "در حین ارسال پیغام خطایی رخ داد.", + "error_loading_chat": "در هنگام بارگذاری گپ خطایی رخ داد.", + "delete_confirm": "آیا از حذف این پیغام اطمینان دارید؟", + "more": "بیشتر", + "empty_message_error": "نمیتوان پیغام خالی فرستاد", + "new": "گپ جدید", + "chats": "گپها" + } +} diff --git a/src/i18n/fi.json b/src/i18n/fi.json index 3832dcaa..2524f278 100644 --- a/src/i18n/fi.json +++ b/src/i18n/fi.json @@ -578,7 +578,8 @@ "show_full_subject": "Näytä koko otsikko", "hide_full_subject": "Piilota koko otsikko", "show_content": "Näytä sisältö", - "hide_content": "Piilota sisältö" + "hide_content": "Piilota sisältö", + "status_deleted": "Poistettu viesti" }, "user_card": { "approve": "Hyväksy", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 3b7eefaf..63ad46d2 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -13,7 +13,8 @@ "scope_options": "Options de visibilité", "text_limit": "Limite de texte", "title": "Caractéristiques", - "who_to_follow": "Personnes à suivre" + "who_to_follow": "Personnes à suivre", + "pleroma_chat_messages": "Chat Pleroma" }, "finder": { "error_fetching_user": "Erreur lors de la recherche de l'utilisateur·ice", @@ -32,7 +33,12 @@ "enable": "Activer", "confirm": "Confirmer", "verify": "Vérifier", - "dismiss": "Rejeter" + "dismiss": "Rejeter", + "peek": "Jeter un coup d'œil", + "close": "Fermer", + "retry": "Réessayez", + "error_retry": "Veuillez réessayer", + "loading": "Chargement…" }, "image_cropper": { "crop_picture": "Rogner l'image", @@ -77,15 +83,17 @@ "dms": "Messages directs", "public_tl": "Fil d'actualité public", "timeline": "Fil d'actualité", - "twkn": "Ensemble du réseau connu", + "twkn": "Réseau connu", "user_search": "Recherche d'utilisateur·ice", "who_to_follow": "Qui suivre", "preferences": "Préférences", "search": "Recherche", - "administration": "Administration" + "administration": "Administration", + "chats": "Chats", + "bookmarks": "Marques-Pages" }, "notifications": { - "broken_favorite": "Chargement d'un message inconnu…", + "broken_favorite": "Message inconnu, chargement…", "favorited_you": "a aimé votre statut", "followed_you": "a commencé à vous suivre", "load_older": "Charger les notifications précédentes", @@ -115,7 +123,7 @@ "text/bbcode": "BBCode" }, "content_warning": "Sujet (optionnel)", - "default": "Écrivez ici votre prochain statut.", + "default": "Je viens d'atterrir en Tchéquie.", "direct_warning_to_all": "Ce message sera visible pour toutes les personnes mentionnées.", "direct_warning_to_first_only": "Ce message sera visible uniquement pour personnes mentionnées au début du message.", "posting": "Envoi en cours", @@ -129,7 +137,12 @@ "private": "Abonné·e·s uniquement - Seul·e·s vos abonné·e·s verront vos billets", "public": "Publique - Afficher dans les fils publics", "unlisted": "Non-Listé - Ne pas afficher dans les fils publics" - } + }, + "media_description_error": "Échec de téléversement du media, essayez encore", + "empty_status_error": "Impossible de poster un statut vide sans attachements", + "preview_empty": "Vide", + "preview": "Prévisualisation", + "media_description": "Description de l'attachement" }, "registration": { "bio": "Biographie", @@ -488,7 +501,15 @@ "notification_setting_privacy_option": "Masquer l'expéditeur et le contenu des notifications push", "notification_setting_privacy": "Intimité", "hide_followers_count_description": "Masquer le nombre d'abonnés", - "accent": "Accent" + "accent": "Accent", + "chatMessageRadius": "Message de chat", + "bot": "Ce compte est un robot", + "import_mutes_from_a_csv_file": "Importer les masquages depuis un fichier CSV", + "mutes_imported": "Masquages importés ! Leur application peut prendre du temps.", + "mute_import_error": "Erreur à l'import des masquages", + "mute_import": "Import des masquages", + "mute_export_button": "Exporter vos masquages dans un fichier CSV", + "mute_export": "Export des masquages" }, "timeline": { "collapse": "Fermer", @@ -732,5 +753,11 @@ "return_home": "Retourner à la page d'accueil", "too_many_requests": "Vos avez atteint la limite d'essais, essayez plus tard.", "password_reset_required": "Vous devez changer votre mot de passe pour vous authentifier." + }, + "errors": { + "storage_unavailable": "Pleroma n'a pas pu accéder au stockage du navigateur. Votre identifiant ou vos mots de passes ne seront sauvegardés et des problèmes pourront être rencontrés. Essayez d'activer les cookies." + }, + "shoutbox": { + "title": "Shoutbox" } } diff --git a/src/i18n/it.json b/src/i18n/it.json index 474e7fde..67e92b32 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -407,7 +407,14 @@ "reset_background_confirm": "Vuoi veramente azzerare lo sfondo?", "chatMessageRadius": "Messaggi istantanei", "notification_setting_hide_notification_contents": "Nascondi mittente e contenuti delle notifiche push", - "notification_setting_block_from_strangers": "Blocca notifiche da utenti che non segui" + "notification_setting_block_from_strangers": "Blocca notifiche da utenti che non segui", + "virtual_scrolling": "Velocizza l'elaborazione delle sequenze", + "import_mutes_from_a_csv_file": "Importa silenziati da un file CSV", + "mutes_imported": "Silenziati importati! Saranno elaborati a breve.", + "mute_import_error": "Errore nell'importazione", + "mute_import": "Importa silenziati", + "mute_export_button": "Esporta la tua lista di silenziati in un file CSV", + "mute_export": "Esporta silenziati" }, "timeline": { "error_fetching": "Errore nell'aggiornamento", @@ -591,12 +598,12 @@ "reject": "Rifiuta", "accept": "Accetta", "simple_policies": "Regole specifiche alla stanza", - "accept_desc": "Questa stanza accetta messaggi solo dalle seguenti stanze:", - "reject_desc": "Questa stanza non accetterà messaggi dalle stanze seguenti:", + "accept_desc": "Questa stanza accetta messaggi solo dalle seguenti altre:", + "reject_desc": "Questa stanza rifiuterà i messaggi provenienti dalle seguenti:", "quarantine": "Quarantena", - "quarantine_desc": "Questa stanza inoltrerà solo messaggi pubblici alle seguenti stanze:", + "quarantine_desc": "Questa stanza inoltrerà solo messaggi pubblici alle seguenti:", "ftl_removal": "Rimozione dalla sequenza globale", - "ftl_removal_desc": "Questa stanza rimuove le seguenti stanze dalla sequenza globale:", + "ftl_removal_desc": "Questa stanza rimuove le seguenti dalla sequenza globale:", "media_removal": "Rimozione multimedia", "media_removal_desc": "Questa istanza rimuove gli allegati dalle seguenti stanze:", "media_nsfw": "Allegati oscurati forzatamente", @@ -695,7 +702,8 @@ "reply_to": "Rispondi a", "delete_confirm": "Vuoi veramente eliminare questo messaggio?", "unbookmark": "Rimuovi segnalibro", - "bookmark": "Aggiungi segnalibro" + "bookmark": "Aggiungi segnalibro", + "status_deleted": "Questo messagio è stato cancellato" }, "time": { "years_short": "{0}a", diff --git a/src/i18n/pl.json b/src/i18n/pl.json index 05a7edf7..67cf38a5 100644 --- a/src/i18n/pl.json +++ b/src/i18n/pl.json @@ -49,7 +49,8 @@ "scope_options": "Ustawienia zakresu", "text_limit": "Limit tekstu", "title": "Funkcje", - "who_to_follow": "Propozycje obserwacji" + "who_to_follow": "Propozycje obserwacji", + "pleroma_chat_messages": "Czat Pleromy" }, "finder": { "error_fetching_user": "Błąd przy pobieraniu profilu", @@ -71,7 +72,9 @@ "verify": "Zweryfikuj", "close": "Zamknij", "loading": "Ładowanie…", - "retry": "Spróbuj ponownie" + "retry": "Spróbuj ponownie", + "peek": "Spójrz", + "error_retry": "Spróbuj ponownie" }, "image_cropper": { "crop_picture": "Przytnij obrazek", @@ -117,12 +120,14 @@ "dms": "Wiadomości prywatne", "public_tl": "Publiczna oś czasu", "timeline": "Oś czasu", - "twkn": "Cała znana sieć", + "twkn": "Znana sieć", "user_search": "Wyszukiwanie użytkowników", "search": "Wyszukiwanie", "who_to_follow": "Sugestie obserwacji", "preferences": "Preferencje", - "bookmarks": "Zakładki" + "bookmarks": "Zakładki", + "chats": "Czaty", + "timelines": "Osie czasu" }, "notifications": { "broken_favorite": "Nieznany status, szukam go…", @@ -197,7 +202,9 @@ }, "preview_empty": "Pusty", "preview": "Podgląd", - "empty_status_error": "Nie można wysłać pustego wpisu bez plików" + "empty_status_error": "Nie można wysłać pustego wpisu bez plików", + "media_description_error": "Nie udało się zaktualizować mediów, spróbuj ponownie", + "media_description": "Opis mediów" }, "registration": { "bio": "Bio", @@ -400,7 +407,7 @@ "theme_help_v2_1": "Możesz też zastąpić kolory i widoczność poszczególnych komponentów przełączając pola wyboru, użyj „Wyczyść wszystko” aby usunąć wszystkie zastąpienia.", "theme_help_v2_2": "Ikony pod niektórych wpisami są wskaźnikami kontrastu pomiędzy tłem a tekstem, po najechaniu na nie otrzymasz szczegółowe informacje. Zapamiętaj, że jeżeli używasz przezroczystości, wskaźniki pokazują najgorszy możliwy przypadek.", "tooltipRadius": "Etykiety/alerty", - "type_domains_to_mute": "Wpisz domeny, które chcesz wyciszyć", + "type_domains_to_mute": "Wyszukaj domeny, które chcesz wyciszyć", "upload_a_photo": "Wyślij zdjęcie", "user_settings": "Ustawienia użytkownika", "values": { @@ -492,7 +499,8 @@ "tabs": "Karty", "chat": { "outgoing": "Wiadomości wychodzące", - "incoming": "Wiadomości przychodzące" + "incoming": "Wiadomości przychodzące", + "border": "Granica" } }, "radii": { @@ -573,7 +581,22 @@ "add_field": "Dodaj pole" }, "bot": "To konto jest prowadzone przez bota", - "notification_setting_hide_notification_contents": "Ukryj nadawcę i zawartość powiadomień push" + "notification_setting_hide_notification_contents": "Ukryj nadawcę i zawartość powiadomień push", + "notification_setting_block_from_strangers": "Zablokuj powiadomienia od użytkowników których nie obserwujesz", + "virtual_scrolling": "Optymalizuj renderowanie osi czasu", + "reset_background_confirm": "Czy naprawdę chcesz zresetować tło?", + "reset_banner_confirm": "Czy naprawdę chcesz zresetować banner?", + "reset_avatar_confirm": "Czy naprawdę chcesz zresetować awatar?", + "reset_profile_banner": "Zresetuj banner profilowy", + "reset_profile_background": "Zresetuj tło profilowe", + "mutes_and_blocks": "Wyciszenia i blokady", + "chatMessageRadius": "Wiadomość czatu", + "import_mutes_from_a_csv_file": "Zaimportuj wyciszenia z pliku .csv", + "mutes_imported": "Zaimportowano wyciszenia! Przetwarzanie zajmie chwilę.", + "mute_import_error": "Wystąpił błąd podczas importowania wyciszeń", + "mute_import": "Import wyciszeń", + "mute_export_button": "Wyeksportuj swoje wyciszenia do pliku .csv", + "mute_export": "Eksport wyciszeń" }, "time": { "day": "{0} dzień", @@ -639,7 +662,12 @@ "unbookmark": "Usuń z zakładek", "bookmark": "Dodaj do zakładek", "hide_content": "Ukryj zawartość", - "show_content": "Pokaż zawartość" + "show_content": "Pokaż zawartość", + "hide_full_subject": "Ukryj cały temat", + "show_full_subject": "Pokaż cały temat", + "thread_muted_and_words": ", ma słowa:", + "thread_muted": "Wątek wyciszony", + "status_deleted": "Ten wpis został usunięty" }, "user_card": { "approve": "Przyjmij", @@ -723,7 +751,8 @@ "add_reaction": "Dodaj reakcję", "user_settings": "Ustawienia użytkownika", "accept_follow_request": "Akceptuj prośbę o możliwość obserwacji", - "reject_follow_request": "Odrzuć prośbę o możliwość obserwacji" + "reject_follow_request": "Odrzuć prośbę o możliwość obserwacji", + "bookmark": "Zakładka" }, "upload": { "error": { @@ -773,9 +802,17 @@ "error_sending_message": "Coś poszło nie tak podczas wysyłania wiadomości.", "error_loading_chat": "Coś poszło nie tak podczas ładowania czatu.", "empty_message_error": "Nie można wysłać pustej wiadomości", - "new": "Nowy czat" + "new": "Nowy czat", + "empty_chat_list_placeholder": "Nie masz jeszcze żadnych czatów. Zacznij nowy czat!", + "chats": "Czaty" }, "display_date": { "today": "Dzisiaj" + }, + "shoutbox": { + "title": "Shoutbox" + }, + "errors": { + "storage_unavailable": "Pleroma nie mogła uzyskać dostępu do pamięci masowej przeglądarki. Twój login lub lokalne ustawienia nie zostaną zapisane i możesz napotkać problemy. Spróbuj włączyć ciasteczka." } } diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 3444a26d..8f421b50 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -473,5 +473,10 @@ "tool_tip": { "accept_follow_request": "Принять запрос на чтение", "reject_follow_request": "Отклонить запрос на чтение" + }, + "image_cropper": { + "save_without_cropping": "Сохранить не обрезая", + "save": "Сохранить", + "crop_picture": "Обрезать картинку" } } diff --git a/src/i18n/zh.json b/src/i18n/zh.json index 8c693f4d..09e2ab0d 100644 --- a/src/i18n/zh.json +++ b/src/i18n/zh.json @@ -11,9 +11,10 @@ "gopher": "Gopher", "media_proxy": "媒体代理", "scope_options": "可见范围设置", - "text_limit": "文本长度限制", + "text_limit": "文字数量限制", "title": "功能", - "who_to_follow": "推荐关注" + "who_to_follow": "推荐关注", + "pleroma_chat_messages": "Pleroma 聊天" }, "finder": { "error_fetching_user": "获取用户时发生错误", @@ -23,8 +24,8 @@ "apply": "应用", "submit": "提交", "more": "更多", - "generic_error": "发生一个错误", - "optional": "可选项", + "generic_error": "发生了一个错误", + "optional": "可选", "show_more": "展开", "show_less": "收起", "cancel": "取消", @@ -32,7 +33,12 @@ "enable": "启用", "confirm": "确认", "verify": "验证", - "dismiss": "忽略" + "dismiss": "忽略", + "peek": "窥探", + "close": "关闭", + "retry": "重试", + "error_retry": "请重试", + "loading": "载入中…" }, "image_cropper": { "crop_picture": "裁剪图片", @@ -77,37 +83,40 @@ "dms": "私信", "public_tl": "公共时间线", "timeline": "时间线", - "twkn": "所有已知网络", + "twkn": "已知网络", "user_search": "用户搜索", "search": "搜索", "who_to_follow": "推荐关注", "preferences": "偏好设置", - "administration": "管理员" + "administration": "管理员", + "chats": "聊天", + "timelines": "时间线", + "bookmarks": "书签" }, "notifications": { "broken_favorite": "未知的状态,正在搜索中…", - "favorited_you": "收藏了你的状态", + "favorited_you": "喜欢了你的状态", "followed_you": "关注了你", "load_older": "加载更早的通知", "notifications": "通知", - "read": "阅读!", + "read": "已阅!", "repeated_you": "转发了你的状态", "no_more_notifications": "没有更多的通知", - "reacted_with": "和 {0} 互动过", - "migrated_to": "迁移到", + "reacted_with": "作出了 {0} 的反应", + "migrated_to": "迁移到了", "follow_request": "想要关注你" }, "polls": { - "add_poll": "增加问卷调查", + "add_poll": "增加投票", "add_option": "增加选项", "option": "选项", "votes": "投票", "vote": "投票", - "type": "问卷类型", - "single_choice": "单选项", - "multiple_choices": "多选项", - "expiry": "问卷的时间", - "expires_in": "投票于 {0} 内结束", + "type": "投票类型", + "single_choice": "单选", + "multiple_choices": "多选", + "expiry": "投票期限", + "expires_in": "投票于 {0} 后结束", "expired": "投票 {0} 前已结束", "not_enough_options": "投票的选项太少" }, @@ -132,7 +141,7 @@ "text/bbcode": "BBCode" }, "content_warning": "主题(可选)", - "default": "刚刚抵达上海", + "default": "刚刚抵达洛杉矶", "direct_warning_to_all": "本条内容只有被提及的用户能够看到。", "direct_warning_to_first_only": "本条内容只有被在消息开始处提及的用户能够看到。", "posting": "发送", @@ -146,12 +155,17 @@ "private": "仅关注者 - 只有关注了你的人能看到", "public": "公共 - 发送到公共时间轴", "unlisted": "不公开 - 不会发送到公共时间轴" - } + }, + "preview_empty": "空的", + "preview": "预览", + "media_description": "媒体描述", + "media_description_error": "更新媒体失败,请重试", + "empty_status_error": "不能发布没有内容、没有附件的发文" }, "registration": { "bio": "简介", "email": "电子邮箱", - "fullname": "全名", + "fullname": "显示名称", "password_confirm": "确认密码", "registration": "注册", "token": "邀请码", @@ -175,7 +189,7 @@ "settings": { "app_name": "App 名称", "security": "安全", - "enter_current_password_to_confirm": "输入你当前密码来确认你的身份", + "enter_current_password_to_confirm": "输入您当前的密码来确认您的身份", "mfa": { "otp": "OTP", "setup_otp": "设置 OTP", @@ -183,18 +197,18 @@ "confirm_and_enable": "确认并启用 OTP", "title": "双因素验证", "generate_new_recovery_codes": "生成新的恢复码", - "warning_of_generate_new_codes": "当你生成新的恢复码时,你的旧恢复码就失效了。", + "warning_of_generate_new_codes": "当您生成新的恢复码时,您旧的恢复码将会失效。", "recovery_codes": "恢复码。", "waiting_a_recovery_codes": "正在接收备份码…", - "recovery_codes_warning": "抄写这些号码,或者保存在安全的地方。这些号码不会再次显示。如果你无法访问你的 2FA app,也丢失了你的恢复码,你的账号就再也无法登录了。", + "recovery_codes_warning": "抄写这些号码,或者将其保存在安全的地方。这些号码不会再次显示。如果您无法访问您的 2FA app,也丢失了您的恢复码,您就再也无法登录您的账号了。", "authentication_methods": "身份验证方法", "scan": { "title": "扫一下", - "desc": "使用你的双因素验证 app,扫描这个二维码,或者输入这些文字密钥:", + "desc": "使用您的双因素验证 app,扫描这个二维码,或者输入这些文字密钥:", "secret_code": "密钥" }, "verify": { - "desc": "要启用双因素验证,请把你的双因素验证 app 里的数字输入:" + "desc": "要启用双因素验证,请输入您的双因素验证 app 里的数字:" } }, "attachmentRadius": "附件", @@ -204,12 +218,12 @@ "avatarRadius": "头像", "background": "背景", "bio": "简介", - "block_export": "拉黑名单导出", - "block_export_button": "导出你的拉黑名单到一个 csv 文件", - "block_import": "拉黑名单导入", - "block_import_error": "导入拉黑名单出错", - "blocks_imported": "拉黑名单导入成功!需要一点时间来处理。", - "blocks_tab": "块", + "block_export": "屏蔽名单导出", + "block_export_button": "导出你的屏蔽名单到一个 csv 文件", + "block_import": "屏蔽名单导入", + "block_import_error": "导入屏蔽名单出错", + "blocks_imported": "屏蔽名单导入成功!需要一点时间来处理。", + "blocks_tab": "屏蔽", "btnRadius": "按钮", "cBlue": "蓝色(回复,关注)", "cGreen": "绿色(转发)", @@ -229,7 +243,7 @@ "delete_account": "删除账户", "delete_account_description": "永久删除你的帐号和所有数据。", "delete_account_error": "删除账户时发生错误,如果一直删除不了,请联系实例管理员。", - "delete_account_instructions": "在下面输入你的密码来确认删除账户", + "delete_account_instructions": "在下面输入您的密码来确认删除账户。", "avatar_size_instruction": "推荐的头像图片最小的尺寸是 150x150 像素。", "export_theme": "导出预置主题", "filtering": "过滤器", @@ -245,7 +259,7 @@ "hide_attachments_in_tl": "在时间线上隐藏附件", "hide_muted_posts": "不显示被隐藏的用户的帖子", "max_thumbnails": "最多再每个帖子所能显示的缩略图数量", - "hide_isp": "隐藏指定实例的面板H", + "hide_isp": "隐藏实例独有的面板", "preload_images": "预载图片", "use_one_click_nsfw": "点击一次以打开工作场所不适宜的附件", "hide_post_stats": "隐藏推文相关的统计数据(例如:收藏的次数)", @@ -263,7 +277,7 @@ "invalid_theme_imported": "您所选择的主题文件不被 Pleroma 支持,因此主题未被修改。", "limited_availability": "在您的浏览器中无法使用", "links": "链接", - "lock_account_description": "你需要手动审核关注请求", + "lock_account_description": "您需要手动审核关注请求", "loop_video": "循环视频", "loop_video_silent_only": "只循环没有声音的视频(例如:Mastodon 里的“GIF”)", "mutes_tab": "隐藏", @@ -278,7 +292,7 @@ "notification_visibility_mentions": "提及", "notification_visibility_repeats": "转发", "no_rich_text_description": "不显示富文本格式", - "no_blocks": "没有拉黑的", + "no_blocks": "没有屏蔽", "no_mutes": "没有隐藏", "hide_follows_description": "不要显示我所关注的人", "hide_followers_description": "不要显示关注我的人", @@ -308,7 +322,7 @@ "search_user_to_mute": "搜索你想要隐藏的用户", "security_tab": "安全", "scope_copy": "回复时的复制范围(私信是总是复制的)", - "minimal_scopes_mode": "最小发文范围", + "minimal_scopes_mode": "使发文可见范围的选项最少化", "set_new_avatar": "设置新头像", "set_new_profile_background": "设置新的个人资料背景", "set_new_profile_banner": "设置新的横幅图片", @@ -324,7 +338,7 @@ "text": "文本", "theme": "主题", "theme_help": "使用十六进制代码(#rrggbb)来设置主题颜色。", - "theme_help_v2_1": "你也可以通过切换复选框来覆盖某些组件的颜色和透明。使用“清除所有”来清楚所有覆盖设置。", + "theme_help_v2_1": "您也可以通过选中复选框来覆盖某些组件的颜色和透明度。使用“清除所有”按钮来清除所有覆盖设置。", "theme_help_v2_2": "某些条目下的图标是背景或文本对比指示器,鼠标悬停可以获取详细信息。请记住,使用透明度来显示最差的情况。", "tooltipRadius": "提醒", "upload_a_photo": "上传照片", @@ -335,7 +349,7 @@ }, "notifications": "通知", "notification_mutes": "要停止收到某个指定的用户的通知,请使用隐藏功能。", - "notification_blocks": "拉黑一个用户会停掉所有他的通知,等同于取消关注。", + "notification_blocks": "屏蔽一个用户会停止接收来自该用户的所有通知,并且会取消对该用户的关注。", "enable_web_push_notifications": "启用 web 推送通知", "style": { "switcher": { @@ -350,7 +364,17 @@ "clear_opacity": "清除透明度", "load_theme": "加载主题", "help": { - "upgraded_from_v2": "PleromaFE 已升级,主题会和你记忆中的不太一样。" + "upgraded_from_v2": "PleromaFE 已升级,主题会与您记忆中的不太一样。", + "older_version_imported": "您导入的文件来自旧版本的 FE。", + "future_version_imported": "您导入的文件来自更高版本的 FE。", + "v2_imported": "您导入的文件是旧版 FE 的。我们尽可能保持兼容性,但还是可能出现不一致的情况。", + "snapshot_source_mismatch": "版本冲突:很有可能是 FE 版本回滚后再次升级了,如果您使用旧版本的 FE 更改了主题那么您可能需要使用旧版本,否则请使用新版本。", + "migration_napshot_gone": "不知出于何种原因,主题快照缺失了,一些地方可能与您印象中的不符。", + "migration_snapshot_ok": "为保万无一失,加载了主题快照。您可以试着加载主题数据。", + "fe_downgraded": "PleromaFE 的版本回滚了。", + "fe_upgraded": "PleromaFE 的主题引擎随着版本更新升级了。", + "snapshot_missing": "在文件中没有主题快照,所以网站外观可能会与原来预想的不同。", + "snapshot_present": "主题快照已加载,因此所有的值均被覆盖。您可以改为加载主题的实际数据。" }, "use_source": "新版本", "use_snapshot": "老版本", @@ -389,7 +413,26 @@ "borders": "边框", "buttons": "按钮", "inputs": "输入框", - "faint_text": "灰度文字" + "faint_text": "灰度文字", + "chat": { + "border": "边框", + "outgoing": "发出的", + "incoming": "收到的" + }, + "disabled": "禁用的", + "pressed": "按下的", + "highlight": "强调元素", + "selectedMenu": "选中的菜单项", + "selectedPost": "选中的发布内容", + "icons": "图标", + "poll": "投票统计图", + "popover": "提示框,菜单,弹出框", + "post": "发布内容/用户简介", + "alert_neutral": "中性", + "alert_warning": "警告", + "tabs": "标签页", + "underlay": "底衬", + "toggled": "勾选的" }, "radii": { "_tab_label": "圆角" @@ -426,7 +469,7 @@ }, "fonts": { "_tab_label": "字体", - "help": "给用户界面的元素选择字体。选择 “自选”的你必须输入确切的字体名称。", + "help": "为用户界面的元素选择字体。若选择 “自选”,您必须输入与系统显示完全一致的字体名称。", "components": { "interface": "界面", "input": "输入框", @@ -461,7 +504,7 @@ "notification_setting_filters": "过滤器", "domain_mutes": "域名", "changed_email": "邮箱修改成功!", - "change_email_error": "修改你的电子邮箱时发生错误", + "change_email_error": "修改您的电子邮箱时发生错误。", "change_email": "修改电子邮箱", "allow_following_move": "正在关注的账号迁移时自动重新关注", "notification_setting_privacy_option": "在通知推送中隐藏发送者和内容", @@ -470,7 +513,41 @@ "notification_visibility_emoji_reactions": "互动", "notification_visibility_moves": "用户迁移", "new_email": "新邮箱", - "emoji_reactions_on_timeline": "在时间线上显示表情符号互动" + "emoji_reactions_on_timeline": "在时间线上显示表情符号互动", + "notification_setting_hide_notification_contents": "隐藏推送通知中的发送者与内容信息", + "notification_setting_block_from_strangers": "屏蔽来自你没有关注的用户的通知", + "type_domains_to_mute": "搜索需要隐藏的域名", + "useStreamingApi": "实时接收帖子和通知", + "user_mutes": "用户", + "reset_background_confirm": "您确定要重置个人资料背景图吗?", + "reset_banner_confirm": "您确定要重置横幅图片吗?", + "reset_avatar_confirm": "您确定要重置头像吗?", + "reset_profile_banner": "重置横幅图片", + "reset_profile_background": "重置个人资料背景图", + "reset_avatar": "重置头像", + "hide_followers_count_description": "不显示关注者数量", + "profile_fields": { + "value": "内容", + "name": "标签", + "add_field": "添加字段", + "label": "个人资料元数据" + }, + "accent": "强调色", + "pad_emoji": "从表情符号选择器插入表情符号时,在表情两侧插入空格", + "discoverable": "允许通过搜索检索等服务找到此账号", + "mutes_and_blocks": "隐藏与屏蔽", + "bot": "这是一个机器人账号", + "fun": "趣味", + "useStreamingApiWarning": "(不推荐使用,试验性,已知会跳过一些帖子)", + "chatMessageRadius": "聊天消息", + "greentext": "Meme 箭头", + "virtual_scrolling": "优化时间线渲染", + "import_mutes_from_a_csv_file": "从 csv 文件导入隐藏名单", + "mutes_imported": "隐藏名单导入成功!处理它们将需要一段时间。", + "mute_import_error": "导入隐藏名单出错", + "mute_import": "隐藏名单导入", + "mute_export_button": "导出你的隐藏名单到一个 csv 文件", + "mute_export": "隐藏名单导出" }, "time": { "day": "{0} 天", @@ -516,7 +593,8 @@ "show_new": "显示新内容", "up_to_date": "已是最新", "no_more_statuses": "没有更多的状态", - "no_statuses": "没有状态更新" + "no_statuses": "没有状态更新", + "reload": "重新载入" }, "status": { "favorites": "收藏", @@ -529,7 +607,18 @@ "reply_to": "回复", "replies_list": "回复:", "mute_conversation": "隐藏对话", - "unmute_conversation": "对话取消隐藏" + "unmute_conversation": "对话取消隐藏", + "hide_content": "隐藏内容", + "show_content": "显示内容", + "hide_full_subject": "隐藏此部分标题", + "show_full_subject": "显示全部标题", + "thread_muted": "此系列消息已被隐藏", + "copy_link": "复制状态链接", + "status_unavailable": "状态不可取得", + "unbookmark": "取消书签", + "bookmark": "书签", + "thread_muted_and_words": ",含有过滤词:", + "status_deleted": "该状态已被删除" }, "user_card": { "approve": "允许", @@ -556,9 +645,9 @@ "statuses": "状态", "subscribe": "订阅", "unsubscribe": "退订", - "unblock": "取消拉黑", - "unblock_progress": "取消拉黑中…", - "block_progress": "拉黑中…", + "unblock": "取消屏蔽", + "unblock_progress": "正在取消屏蔽…", + "block_progress": "正在屏蔽…", "unmute": "取消隐藏", "unmute_progress": "取消隐藏中…", "mute_progress": "隐藏中…", @@ -579,11 +668,13 @@ "disable_any_subscription": "完全禁止关注用户", "quarantine": "从联合实例中禁止用户帖子", "delete_user": "删除用户", - "delete_user_confirmation": "你确认吗?此操作无法撤销。" + "delete_user_confirmation": "你确定吗?此操作无法撤销。" }, "hidden": "已隐藏", "show_repeats": "显示转发", - "hide_repeats": "隐藏转发" + "hide_repeats": "隐藏转发", + "message": "消息", + "mention": "提及" }, "user_profile": { "timeline_title": "用户时间线", @@ -592,12 +683,12 @@ }, "user_reporting": { "title": "报告 {0}", - "add_comment_description": "此报告会发送给你的实例管理员。你可以在下面提供更多详细信息解释报告的缘由:", + "add_comment_description": "此报告会发送给您的实例管理员。您可以在下面提供更多详细信息解释报告的缘由:", "additional_comments": "其它信息", "forward_description": "这个账号是从另外一个服务器。同时发送一个副本到那里?", "forward_to": "转发 {0}", "submit": "提交", - "generic_error": "当处理你的请求时,发生了一个错误。" + "generic_error": "当处理您的请求时,发生了一个错误。" }, "who_to_follow": { "more": "更多", @@ -610,7 +701,9 @@ "favorite": "收藏", "user_settings": "用户设置", "reject_follow_request": "拒绝关注请求", - "add_reaction": "添加互动" + "add_reaction": "添加互动", + "bookmark": "书签", + "accept_follow_request": "接受关注请求" }, "upload": { "error": { @@ -628,7 +721,7 @@ }, "search": { "people": "人", - "hashtags": "Hashtags", + "hashtags": "话题标签", "person_talking": "{count} 人正在讨论", "people_talking": "{count} 人正在讨论", "no_results": "没有搜索结果" @@ -636,12 +729,14 @@ "password_reset": { "forgot_password": "忘记密码了?", "password_reset": "重置密码", - "instruction": "输入你的电邮地址或者用户名,我们将发送一个链接到你的邮箱,用于重置密码。", - "placeholder": "你的电邮地址或者用户名", - "check_email": "检查你的邮箱,会有一个链接用于重置密码。", + "instruction": "输入您的电邮地址或者用户名,我们将发送一个链接到您的邮箱,用于重置密码。", + "placeholder": "您的电邮地址或者用户名", + "check_email": "检查您的邮箱,会有一个链接用于重置密码。", "return_home": "回到首页", - "too_many_requests": "你触发了尝试的限制,请稍后再试。", - "password_reset_disabled": "密码重置已经被禁用。请联系你的实例管理员。" + "too_many_requests": "您达到了尝试次数的上限,请稍后再试。", + "password_reset_disabled": "密码重置已被禁用。请联系您的实例管理员。", + "password_reset_required_but_mailer_is_disabled": "您必须重置密码,但是密码重置被禁用了。请联系您所在实例的管理员。", + "password_reset_required": "您必须重置密码才能登陆。" }, "remote_user_resolver": { "error": "未找到。", @@ -650,27 +745,34 @@ }, "emoji": { "keep_open": "选择器保持打开", - "stickers": "贴图", + "stickers": "贴纸", "unicode": "Unicode 表情符号", "custom": "自定义表情符号", "add_emoji": "插入表情符号", "search_emoji": "搜索表情符号", - "emoji": "表情符号" + "emoji": "表情符号", + "load_all": "加载所有表情符号(共 {emojiAmount} 个)", + "load_all_hint": "最先加载的 {saneAmount} 表情符号,加载全部表情符号可能会带来性能问题。" }, "about": { "mrf": { "simple": { - "quarantine_desc": "本实例只会把公开状态发送非下列实例:", + "quarantine_desc": "本实例向以下实例仅发送公开的帖子:", "quarantine": "隔离", "reject_desc": "本实例不会接收来自下列实例的消息:", "reject": "拒绝", "accept_desc": "本实例只接收来自下列实例的消息:", - "simple_policies": "站规", + "simple_policies": "对于特定实例的策略", "accept": "接受", - "media_removal": "移除媒体" + "media_removal": "移除媒体", + "media_nsfw_desc": "本实例将来自以下实例的媒体内容强制设置为敏感内容:", + "media_nsfw": "强制设置媒体为敏感内容", + "media_removal_desc": "本实例移除来自以下实例的媒体内容:", + "ftl_removal_desc": "该实例在从“全部已知网络”时间线上移除了下列实例:", + "ftl_removal": "从“全部已知网络”时间线上移除" }, "mrf_policies_desc": "MRF 策略会影响本实例的互通行为。以下策略已启用:", - "mrf_policies": "已启动 MRF 策略", + "mrf_policies": "已启动的 MRF 策略", "keyword": { "ftl_removal": "从“全部已知网络”时间线上移除", "keyword_policies": "关键词策略", @@ -678,13 +780,42 @@ "replace": "替换", "reject": "拒绝" }, - "federation": "联邦" - } + "federation": "联邦互通" + }, + "staff": "管理人员" }, "domain_mute_card": { "unmute_progress": "正在取消隐藏…", "unmute": "取消隐藏", "mute_progress": "隐藏中…", "mute": "隐藏" + }, + "errors": { + "storage_unavailable": "Pleroma 无法访问浏览器储存。您的登陆名以及本地设置将不会被保存,您可能遇到意外问题。请尝试启用 cookies。" + }, + "shoutbox": { + "title": "留言板" + }, + "display_date": { + "today": "今天" + }, + "file_type": { + "file": "文件", + "image": "图片", + "video": "视频", + "audio": "音频" + }, + "chats": { + "empty_chat_list_placeholder": "您还没有任何聊天记录。开始聊天吧!", + "error_sending_message": "发送消息时出了点问题。", + "error_loading_chat": "加载聊天时出了点问题。", + "delete_confirm": "您确实要删除此消息吗?", + "more": "更多", + "empty_message_error": "无法发布空消息", + "new": "新聊天", + "chats": "聊天", + "delete": "删除", + "message_user": "发消息给 {nickname}", + "you": "你:" } } diff --git a/src/i18n/zh_Hant.json b/src/i18n/zh_Hant.json new file mode 100644 index 00000000..f2625116 --- /dev/null +++ b/src/i18n/zh_Hant.json @@ -0,0 +1,810 @@ +{ + "emoji": { + "unicode": "統一碼繪文字", + "custom": "自定義繪文字", + "add_emoji": "插入繪文字", + "search_emoji": "搜索繪文字", + "keep_open": "選擇器保持打開", + "emoji": "繪文字", + "stickers": "貼紙", + "load_all": "加載所有繪文字(共 {emojiAmount} 個)", + "load_all_hint": "最先加載的 {saneAmount} ,加載全部繪文字可能會帶來性能問題。" + }, + "polls": { + "not_enough_options": "投票的選項太少", + "expired": "投票 {0} 前已結束", + "expires_in": "投票於 {0} 內結束", + "expiry": "投票期限", + "multiple_choices": "多選", + "single_choice": "單選", + "type": "問卷類型", + "vote": "投票", + "votes": "票", + "option": "選項", + "add_option": "增加選項", + "add_poll": "增加投票" + }, + "notifications": { + "reacted_with": "和 {0} 互動過", + "migrated_to": "遷移到", + "no_more_notifications": "沒有更多的通知", + "repeated_you": "轉發了你的發文", + "read": "已閱!", + "notifications": "通知", + "load_older": "載入更早的通知", + "follow_request": "想要關注你", + "followed_you": "關注了你", + "favorited_you": "喜歡了你的發文", + "broken_favorite": "未知的狀態,正在搜索中…" + }, + "nav": { + "chats": "聊天", + "timelines": "時間線", + "preferences": "偏好設置", + "who_to_follow": "推薦關注", + "search": "搜索", + "user_search": "用戶搜索", + "bookmarks": "書籤", + "twkn": "已知網絡", + "timeline": "時間線", + "public_tl": "公共時間線", + "dms": "私信", + "interactions": "互動", + "mentions": "提及", + "friend_requests": "關注請求", + "back": "後退", + "administration": "管理", + "about": "關於" + }, + "media_modal": { + "next": "往後", + "previous": "往前" + }, + "login": { + "heading": { + "recovery": "雙重因素恢復", + "totp": "雙重因素驗證" + }, + "recovery_code": "恢復碼", + "enter_two_factor_code": "輸入一個雙重因素驗證碼", + "enter_recovery_code": "輸入一個恢復碼", + "authentication_code": "驗證碼", + "hint": "登錄後加入討論", + "username": "用戶名", + "register": "註冊", + "placeholder": "例:鈴音", + "password": "密碼", + "logout": "登出", + "description": "用 OAuth 登入", + "login": "登入" + }, + "importer": { + "error": "導入此文件時出現一個錯誤。", + "success": "導入成功。", + "submit": "提交" + }, + "image_cropper": { + "cancel": "取消", + "save_without_cropping": "保存不裁剪", + "save": "保存", + "crop_picture": "裁剪圖片" + }, + "general": { + "peek": "窺視", + "close": "關閉", + "verify": "驗證", + "confirm": "確認", + "enable": "啟用", + "disable": "禁用", + "cancel": "取消", + "dismiss": "忽略", + "show_less": "收起", + "show_more": "展開", + "optional": "可選", + "retry": "再試", + "error_retry": "請再試", + "generic_error": "發生一個錯誤", + "loading": "載入中…", + "more": "更多", + "submit": "提交", + "apply": "應用" + }, + "finder": { + "find_user": "尋找用戶", + "error_fetching_user": "獲取用戶時發生錯誤" + }, + "features_panel": { + "who_to_follow": "推薦關注", + "title": "特色", + "text_limit": "文字數量限制", + "scope_options": "可見範圍設置", + "media_proxy": "媒體代理", + "pleroma_chat_messages": "Pleroma 聊天", + "chat": "聊天", + "gopher": "Gopher" + }, + "exporter": { + "processing": "正在處理,稍後會提示您下載文件", + "export": "導出" + }, + "domain_mute_card": { + "unmute_progress": "取消靜音中…", + "unmute": "取消靜音", + "mute_progress": "靜音中…", + "mute": "靜音" + }, + "shoutbox": { + "title": "留言板" + }, + "about": { + "staff": "職員", + "mrf": { + "simple": { + "media_nsfw_desc": "這個實例強迫以下實例的帖子媒體設定為敏感:", + "media_nsfw": "媒體強制設定為敏感", + "media_removal_desc": "這個實例移除以下實例的帖子媒體:", + "media_removal": "移除媒體", + "ftl_removal_desc": "這個實例在所有已知網絡中移除下列實例:", + "ftl_removal": "從所有已知網路中移除", + "quarantine_desc": "本實例只會把公開發文發送到下列實例:", + "quarantine": "隔離", + "reject_desc": "本實例不會接收來自下列實例的消息:", + "reject": "拒絕", + "accept_desc": "本實例只接收來自下列實例的消息:", + "simple_policies": "站規", + "accept": "接受" + }, + "mrf_policies_desc": "MRF 策略會影響本實例的互通行為。以下策略已啟用:", + "keyword": { + "ftl_removal": "從“全部已知網絡”時間線上移除", + "replace": "取代", + "reject": "拒絕", + "is_replaced_by": "→", + "keyword_policies": "關鍵字政策" + }, + "mrf_policies": "已啟用的MRF政策", + "federation": "聯邦" + } + }, + "settings": { + "style": { + "common": { + "color": "顏色", + "contrast": { + "context": { + "18pt": "大字文本 (18pt+)", + "text": "文本" + }, + "level": { + "aaa": "符合 AAA 等級準則(推薦)", + "aa": "符合 AA 等級準則(最低)", + "bad": "不符合任何輔助功能指南" + }, + "hint": "對比度是 {ratio}, 它 {level} {context}" + }, + "opacity": "透明度" + }, + "advanced_colors": { + "faint_text": "灰度文字", + "alert_error": "錯誤", + "badge_notification": "通知", + "alert": "提醒或警告背景色", + "_tab_label": "高级", + "alert_warning": "警告", + "alert_neutral": "中性", + "post": "帖子/用戶簡介", + "badge": "徽章背景", + "popover": "提示框,菜單,彈出框", + "panel_header": "面板標題", + "top_bar": "頂欄", + "borders": "邊框", + "buttons": "按鈕", + "inputs": "輸入框", + "underlay": "底襯", + "poll": "投票統計圖", + "icons": "圖標", + "highlight": "強調元素", + "pressed": "按下", + "selectedPost": "選中的帖子", + "selectedMenu": "選中的菜單項", + "disabled": "關閉", + "toggled": "切換", + "tabs": "標籤", + "chat": { + "incoming": "收到", + "outgoing": "發出", + "border": "邊框" + } + }, + "preview": { + "header_faint": "這很正常", + "header": "預覽", + "content": "內容", + "error": "例子錯誤", + "button": "按鈕", + "text": "有堆 {0} 和 {1}", + "mono": "內容", + "input": "剛剛抵達洛杉磯.", + "faint_link": "有用的手冊", + "fine_print": "閱讀我們的 {0} ,然而什麼有用的也學不到!", + "checkbox": "我已經瀏覽了條款及細則", + "link": "一個很好的小鏈接" + }, + "shadows": { + "override": "覆寫", + "_tab_label": "陰影和燈光", + "component": "組件", + "shadow_id": "陰影 #{value}", + "blur": "模糊", + "spread": "擴散", + "inset": "插圖", + "hintV3": "對於陰影,您還可以使用{0}表示法來使用其他顏色插槽。", + "filter_hint": { + "always_drop_shadow": "警告,此陰影設置會總是使用 {0} ,如果瀏覽器支持的話。", + "drop_shadow_syntax": "{0} 不支持參數 {1} 和關鍵詞 {2} 。", + "avatar_inset": "請注意組合兩個內部和非內部的陰影到頭像上,在透明頭像上可能會有意料之外的效果。", + "spread_zero": "陰影的擴散 > 0 會同設置成零一樣", + "inset_classic": "插入內部的陰影會使用 {0}" + }, + "components": { + "panel": "面板", + "panelHeader": "面板標題", + "topBar": "頂欄", + "avatar": "用戶頭像(在個人資料欄)", + "avatarStatus": "用戶頭像(在帖子顯示欄)", + "popup": "彈窗和工具提示", + "button": "按鈕", + "buttonHover": "按鈕(懸停)", + "buttonPressed": "按鈕(按下)", + "buttonPressedHover": "按鈕(按下和懸停)", + "input": "輸入框" + } + }, + "switcher": { + "use_snapshot": "舊版", + "load_theme": "載入主題", + "keep_color": "保留顏色", + "keep_shadows": "保留陰影", + "keep_opacity": "保留透明度", + "keep_roundness": "保留圓角", + "help": { + "migration_napshot_gone": "不知出於何種原因,主題快照缺失了,一些地方可能與您印象中的不符。", + "snapshot_source_mismatch": "版本衝突:很有可能是 FE 版本回滾後再次升級了,如果您使用舊版本的 FE 更改了主題那麼您可能需要使用舊版本,否則請使用新版本。", + "future_version_imported": "您導入的文件來自更高版本的 FE。", + "older_version_imported": "您導入的文件來自舊版本的 FE。", + "snapshot_missing": "在文件中沒有主題快照,所以網站外觀可能會與原來預想的不同。", + "fe_upgraded": "PleromaFE 的主題引擎隨著版本更新升級了。", + "fe_downgraded": "PleromaFE 的版本回滾了。", + "upgraded_from_v2": "PleromaFE 已升級,主題會和你記憶中的不太一樣。", + "v2_imported": "您導入的文件是舊版 FE 的。我們儘可能保持兼容性,但還是可能出現不一致的情況。", + "snapshot_present": "載入快照已加載,因此所有值均被覆蓋。 您可以改為載入主題實際數據。", + "migration_snapshot_ok": "為保萬無一失,載入了主題快照。您可以試著載入主題數據。" + }, + "use_source": "新版本", + "keep_as_is": "保持原狀", + "clear_opacity": "清除透明度", + "clear_all": "清除全部", + "reset": "重置", + "keep_fonts": "保留字體", + "save_load_hint": "\"保留\" 選項在選擇或載入主題時保留當前設置的選項,在導出主題時還會存儲上述選項。當所有複選框未設置時,導出主題將保存所有內容。" + }, + "fonts": { + "components": { + "interface": "界面", + "input": "輸入框", + "post": "發帖文字", + "postCode": "帖子中使用等間距文字(富文本)" + }, + "_tab_label": "字體", + "help": "給用戶界面的元素選擇字體。選擇 “自選”的你必須輸入確切的字體名稱。", + "family": "字體名稱", + "size": "大小 (像素)", + "weight": "字重 (粗體))", + "custom": "自選" + }, + "common_colors": { + "foreground_hint": "點擊”高級“ 標籤進行細緻的控制", + "main": "常用顏色", + "_tab_label": "共同", + "rgbo": "圖標,強調,徽章" + }, + "radii": { + "_tab_label": "圓角" + } + }, + "notification_setting_block_from_strangers": "屏蔽來自你沒有關注的用戶的通知", + "user_mutes": "用户", + "hide_followers_count_description": "不顯示關注者數量", + "no_rich_text_description": "不顯示富文本格式", + "notification_visibility_moves": "用戶遷移", + "notification_visibility_repeats": "轉發", + "notification_visibility_mentions": "提及", + "notification_visibility_likes": "點贊", + "interfaceLanguage": "界面語言", + "instance_default": "(默認:{value})", + "inputRadius": "輸入框", + "import_theme": "導入預置主題", + "import_followers_from_a_csv_file": "從 csv 文件中導入關注", + "import_blocks_from_a_csv_file": "從 csv 文件中導入封鎖黑名單名單", + "hide_filtered_statuses": "隱藏過濾的發文", + "lock_account_description": "你需要手動審核關注請求", + "loop_video": "循環視頻", + "loop_video_silent_only": "只循環沒有聲音的視頻(例如:Mastodon 裡的“GIF”)", + "mutes_tab": "靜音", + "play_videos_in_modal": "在彈出框內播放視頻", + "profile_fields": { + "add_field": "添加字段", + "name": "標籤", + "value": "內容", + "label": "個人資料元數據" + }, + "use_contain_fit": "生成縮略圖時不要裁剪附件", + "notification_visibility": "要顯示的通知類型", + "notification_visibility_follows": "關注", + "new_email": "新電郵", + "subject_line_mastodon": "比如mastodon: copy as is", + "reset_background_confirm": "您確定要重置個人資料背景圖嗎?", + "reset_banner_confirm": "您確定要重置橫幅圖片嗎?", + "reset_avatar_confirm": "您確定要重置頭像嗎?", + "reset_profile_banner": "重置橫幅圖片", + "reset_profile_background": "重置個人資料背景圖", + "reset_avatar": "重置頭像", + "discoverable": "允許通過搜索檢索等服務找到此賬號", + "delete_account_error": "刪除賬戶時發生錯誤,如果一直刪除不了,請聯繫實例管理員。", + "composing": "正在書寫", + "chatMessageRadius": "聊天訊息", + "mfa": { + "confirm_and_enable": "確認並啟用OTP", + "setup_otp": "設置OTP", + "otp": "OTP", + "wait_pre_setup_otp": "預設OTP", + "verify": { + "desc": "要啟用雙因素驗證,請把你的雙因素驗證 app 裡的數字輸入:" + }, + "scan": { + "secret_code": "密鑰", + "desc": "使用你的雙因素驗證 app,掃瞄這個二維碼,或者輸入這些文字密鑰:", + "title": "掃瞄" + }, + "authentication_methods": "身份驗證方法", + "recovery_codes_warning": "抄寫這些號碼,或者保存在安全的地方。這些號碼不會再次顯示。如果你無法訪問你的 2FA app,也丟失了你的恢復碼,你的賬號就再也無法登錄了。", + "waiting_a_recovery_codes": "正在接收備份碼…", + "recovery_codes": "恢復碼。", + "warning_of_generate_new_codes": "當你生成新的恢復碼時,你的舊恢復碼就失效了。", + "generate_new_recovery_codes": "生成新的恢復碼", + "title": "雙因素驗證" + }, + "new_password": "新密碼", + "name_bio": "名字及簡介", + "name": "名字", + "domain_mutes": "域名", + "delete_account_instructions": "在下面輸入密碼,以確認刪除帳戶。", + "delete_account_description": "永久刪除你的帳號和所有數據。", + "delete_account": "刪除帳戶", + "default_vis": "默認可見性範圍", + "data_import_export_tab": "數據導入/導出", + "mutes_and_blocks": "靜音與封鎖", + "current_password": "當前密碼", + "confirm_new_password": "確認新密碼", + "collapse_subject": "摺疊帶標題的內容", + "checkboxRadius": "複選框", + "instance_default_simple": "(默認)", + "interface": "界面", + "invalid_theme_imported": "您所選擇的主題文件不被 Pleroma 支持,因此主題未被修改。", + "limited_availability": "在您的瀏覽器中無法使用", + "links": "鏈接", + "changed_password": "成功修改了密碼!", + "change_password_error": "修改密碼的時候出了點問題。", + "change_password": "修改密碼", + "changed_email": "郵箱修改成功!", + "bot": "這是一個機器人賬號", + "change_email": "修改電子郵箱", + "cRed": "紅色(取消)", + "cOrange": "橙色(收藏)", + "cGreen": "綠色(轉發)", + "cBlue": "藍色(回覆,關注)", + "btnRadius": "按鈕", + "notification_visibility_emoji_reactions": "互動", + "no_blocks": "沒有封鎖", + "no_mutes": "沒有靜音", + "hide_follows_description": "不要顯示我所關注的人", + "hide_followers_description": "不要顯示關注我的人", + "hide_follows_count_description": "不顯示關注數", + "nsfw_clickthrough": "將敏感附件隱藏,點擊才能打開", + "valid_until": "有效期至", + "panelRadius": "面板", + "pause_on_unfocused": "在離開頁面時暫停時間線推送", + "notifications": "通知", + "notification_setting_filters": "過濾器", + "notification_setting_privacy": "隱私", + "notification_mutes": "要停止收到某個指定的用戶的通知,請使用靜音功能。", + "notification_blocks": "封鎖一個用戶會停掉所有他的通知,等同於取消關注。", + "enable_web_push_notifications": "啟用 web 推送通知", + "presets": "預置", + "profile_background": "個人背景圖", + "profile_banner": "橫幅圖片", + "profile_tab": "個人資料", + "radii_help": "設置界面邊緣的圓角 (單位:像素)", + "reply_visibility_all": "顯示所有回覆", + "autohide_floating_post_button": "自動隱藏新帖子的按鈕(移動設備)", + "saving_err": "保存設置時發生錯誤", + "saving_ok": "設置已保存", + "search_user_to_block": "搜索你想屏蔽的用戶", + "search_user_to_mute": "搜索你想要隱藏的用戶", + "security_tab": "安全", + "set_new_avatar": "設置新頭像", + "set_new_profile_background": "設置新的個人背景", + "set_new_profile_banner": "設置新的個人橫幅", + "settings": "設置", + "subject_input_always_show": "總是顯示主題框", + "subject_line_behavior": "回覆時複製主題", + "subject_line_email": "比如電郵: \"re: 主題\"", + "subject_line_noop": "不要複製", + "post_status_content_type": "發文內容類型", + "stop_gifs": "鼠標懸停時播放GIF", + "streaming": "開啟滾動到頂部時的自動推送", + "text": "文本", + "theme": "主題", + "theme_help": "使用十六進制代碼(#rrggbb)來設置主題顏色。", + "theme_help_v2_1": "你也可以通過切換複選框來覆蓋某些組件的顏色和透明。使用“清除所有”來清楚所有覆蓋設置。", + "theme_help_v2_2": "某些條目下的圖標是背景或文本對比指示器,鼠標懸停可以獲取詳細信息。請記住,使用透明度來顯示最差的情況。", + "tooltipRadius": "提醒", + "upload_a_photo": "上傳照片", + "user_settings": "用戶設置", + "values": { + "false": "否", + "true": "是" + }, + "avatar_size_instruction": "推薦的頭像圖片最小的尺寸是 150x150 像素。", + "emoji_reactions_on_timeline": "在時間線上顯示繪文字互動", + "export_theme": "導出預置主題", + "filtering": "過濾", + "filtering_explanation": "所有包含以下詞彙的內容都會被隱藏,一行一個", + "follow_export": "導出關注", + "follow_export_button": "將關注導出成 csv 文件", + "follow_import": "導入關注", + "follow_import_error": "導入關注時錯誤", + "follows_imported": "關注已導入!尚需要一些時間來處理。", + "hide_attachments_in_convo": "在對話中隱藏附件", + "hide_attachments_in_tl": "在時間線上隱藏附件", + "hide_muted_posts": "不顯示被靜音的用戶的帖子", + "max_thumbnails": "最多每個帖子所能顯示的縮略圖數量", + "hide_isp": "隱藏指定實例的面板", + "preload_images": "預載圖片", + "use_one_click_nsfw": "點擊一次以打開工作場所不適宜的附件", + "hide_post_stats": "隱藏帖子的統計數據(例如:收藏的次數)", + "hide_user_stats": "隱藏用戶的統計數據(例如:關注者的數量)", + "general": "通用", + "foreground": "前景", + "blocks_tab": "封鎖", + "blocks_imported": "封鎖黑名單導入成功!需要一點時間來處理。", + "block_import_error": "導入封鎖黑名單出錯", + "block_import": "封鎖黑名單導入", + "block_export_button": "導出你的封鎖黑名單到一個 csv 文件", + "block_export": "封鎖黑名單導出", + "bio": "簡介", + "background": "背景", + "avatarRadius": "頭像", + "avatarAltRadius": "頭像(通知)", + "avatar": "頭像", + "attachments": "附件", + "attachmentRadius": "附件", + "allow_following_move": "正在關注的賬號遷移時自動重新關注", + "enter_current_password_to_confirm": "輸入你當前密碼來確認你的身份", + "security": "安全", + "app_name": "App 名稱", + "change_email_error": "修改你的電子郵箱時發生錯誤。", + "type_domains_to_mute": "搜索需要隱藏的域名", + "pad_emoji": "從繪文字選擇器插入繪文字時,在繪文字兩側插入空格", + "useStreamingApi": "實時接收發佈以及通知", + "minimal_scopes_mode": "最小發文範圍", + "scope_copy": "回覆時的複製範圍(私信是總是複製的)", + "reply_visibility_self": "只顯示發送給我的回覆", + "reply_visibility_following": "只顯示發送給我的回覆/發送給我關注的用戶的回覆", + "replies_in_timeline": "時間線中的回覆", + "revoke_token": "撤消", + "show_admin_badge": "顯示管理徽章", + "accent": "強調色", + "greentext": "前文箭頭", + "show_moderator_badge": "顯示主持人徽章", + "oauth_tokens": "OAuth代幣", + "token": "代幣", + "refresh_token": "刷新代幣", + "useStreamingApiWarning": "(不推薦使用,實驗性的,已知跳過文章)", + "fun": "有趣", + "notification_setting_hide_notification_contents": "隱藏推送通知中的發送者與內容信息", + "version": { + "title": "版本", + "backend_version": "後端版本", + "frontend_version": "前端版本" + }, + "virtual_scrolling": "優化時間線渲染", + "import_mutes_from_a_csv_file": "從CSV文件導入靜音", + "mutes_imported": "靜音導入了!處理它們將需要一段時間。", + "mute_import": "靜音導入", + "mute_import_error": "導入靜音時出錯", + "mute_export_button": "將靜音導出到csv文件", + "mute_export": "靜音導出" + }, + "chats": { + "more": "更多", + "delete_confirm": "您確實要刪除此消息嗎?", + "error_loading_chat": "加載聊天時出了點問題。", + "error_sending_message": "發送消息時出了點問題。", + "empty_chat_list_placeholder": "您還沒有任何聊天記錄。 開始新的聊天!", + "new": "新聊天", + "empty_message_error": "無法發布空消息", + "you": "你:", + "message_user": "發消息給 {nickname}", + "delete": "刪除", + "chats": "聊天" + }, + "file_type": { + "audio": "音頻", + "video": "視頻", + "image": "图片", + "file": "檔案" + }, + "display_date": { + "today": "今天" + }, + "status": { + "mute_conversation": "靜音對話", + "replies_list": "回覆:", + "reply_to": "回覆", + "pin": "在個人資料置頂", + "unpin": "取消在個人資料置頂", + "favorites": "喜歡", + "repeats": "轉發", + "delete": "刪除發文", + "pinned": "置頂", + "bookmark": "書籤", + "unbookmark": "取消書籤", + "delete_confirm": "你真的想要刪除這條發文嗎?", + "unmute_conversation": "對話取消靜音", + "status_unavailable": "發文不可取得", + "copy_link": "複製發文鏈接", + "thread_muted": "静音線程", + "show_full_subject": "顯示完整標題", + "thread_muted_and_words": ",有这些字:", + "hide_full_subject": "隱藏完整標題", + "show_content": "顯示內容", + "hide_content": "隱藏內容" + }, + "time": { + "hours": "{0} 小時", + "days_short": "{0}天", + "day_short": "{0}天", + "days": "{0} 天", + "hour": "{0} 小时", + "hour_short": "{0}h", + "hours_short": "{0}h", + "years_short": "{0} y", + "now": "剛剛", + "day": "{0} 天", + "in_future": "還有 {0}", + "in_past": "{0} 之前", + "minute": "{0} 分鐘", + "minute_short": "{0} 分", + "minutes_short": "{0} 分", + "minutes": "{0} 分鐘", + "month": "{0} 月", + "months": "{0} 月", + "month_short": "{0} 月", + "months_short": "{0} 月", + "now_short": "剛剛", + "second": "{0} 秒", + "seconds": "{0} 秒", + "second_short": "{0} 秒", + "seconds_short": "{0} 秒", + "week": "{0}周", + "weeks": "{0}周", + "week_short": "{0}周", + "weeks_short": "{0}周", + "year": "{0} 年", + "years": "{0} 年", + "year_short": "{0}年" + }, + "post_status": { + "media_description_error": "無法更新媒體,請重試", + "media_description": "媒體描述", + "scope": { + "unlisted": "不公開 - 不會發送到公共時間軸", + "public": "公共 - 發送到公共時間軸", + "private": "僅關注者 - 只有關注了你的人能看到", + "direct": "私信 - 只發送給被提及的用戶" + }, + "scope_notice": { + "unlisted": "本條內容既不在公共時間線,也不會在所有已知網絡上可見", + "private": "關注你的人才能看到本條內容", + "public": "本條帖子可以被所有人看到" + }, + "preview_empty": "空的", + "preview": "預覽", + "posting": "正在發送", + "direct_warning_to_first_only": "本條內容只有被在消息開始處提及的用戶能夠看到。", + "direct_warning_to_all": "本條內容只有被提及的用戶能夠看到。", + "account_not_locked_warning": "你的帳號沒有 {0}。任何人都可以關注你並瀏覽你的上鎖內容。", + "new_status": "發佈新發文", + "content_warning": "主題(可選)", + "content_type": { + "text/bbcode": "BBCode", + "text/markdown": "Markdown", + "text/html": "HTML", + "text/plain": "純文本" + }, + "attachments_sensitive": "標記附件為敏感內容", + "account_not_locked_warning_link": "上鎖", + "default": "剛剛抵達洛杉磯。", + "empty_status_error": "無法發佈沒有附件的空發文" + }, + "errors": { + "storage_unavailable": "Pleroma無法訪問瀏覽器存儲。您的登錄名或本地設置將不會保存,您可能會遇到意外問題。嘗試啟用Cookie。" + }, + "timeline": { + "error_fetching": "獲取更新時發生錯誤", + "conversation": "對話", + "no_retweet_hint": "這條內容僅關注者可見,或者是私信,因此不能轉發", + "collapse": "摺疊", + "load_older": "載入更早的發文", + "repeated": "已轉發", + "show_new": "顯示新內容", + "reload": "重新載入", + "up_to_date": "已是最新", + "no_more_statuses": "没有更多發文", + "no_statuses": "没有發文" + }, + "interactions": { + "load_older": "載入更早的互動", + "moves": "用戶遷移", + "follows": "新的關注者", + "favs_repeats": "轉發和收藏" + }, + "selectable_list": { + "select_all": "選擇全部" + }, + "remote_user_resolver": { + "error": "未找到。", + "searching_for": "搜索", + "remote_user_resolver": "遠程用戶解析器" + }, + "registration": { + "validations": { + "password_confirmation_match": "不能和密碼一樣", + "password_confirmation_required": "不能留空", + "password_required": "不能留空", + "email_required": "不能留空", + "fullname_required": "不能留空", + "username_required": "不能留空" + }, + "fullname": "顯示名稱", + "bio_placeholder": "例如:\n你好,我是玲音。\n我是一個住在日本郊區的動畫少女。你可能在 Wired 見過我。", + "fullname_placeholder": "例如:岩倉玲音", + "username_placeholder": "例如:lain", + "new_captcha": "點擊圖片獲取新的驗證碼", + "captcha": "CAPTCHA", + "token": "邀請碼", + "registration": "註冊", + "password_confirm": "確認密碼", + "email": "電子郵箱", + "bio": "簡介" + }, + "user_card": { + "its_you": "就是你!!", + "media": "媒體", + "per_day": "每天", + "remote_follow": "跨站關注", + "subscribe": "訂閱", + "mute_progress": "靜音中…", + "admin_menu": { + "delete_account": "刪除賬號", + "delete_user": "刪除用戶", + "delete_user_confirmation": "你確認嗎?此操作無法撤銷。", + "moderation": "調停", + "grant_admin": "賦予管理權限", + "revoke_admin": "撤銷管理權限", + "grant_moderator": "賦予主持人權限", + "revoke_moderator": "撤銷主持人權限", + "activate_account": "啟用賬號", + "deactivate_account": "關閉賬號", + "force_nsfw": "標記所有的帖子都是工作場合不適", + "strip_media": "從帖子裡刪除媒體文件", + "force_unlisted": "強制帖子為不公開", + "sandbox": "強制帖子為只有關注者可看", + "disable_remote_subscription": "禁止從遠程實例關注用戶", + "disable_any_subscription": "完全禁止關注用戶", + "quarantine": "從聯合實例中禁止用戶帖子" + }, + "approve": "批准", + "block": "封鎖", + "blocked": "已封鎖!", + "deny": "拒絕", + "favorites": "喜歡", + "follow": "關注", + "follow_sent": "請求已發送!", + "follow_progress": "請求中…", + "follow_again": "再次發送請求?", + "follow_unfollow": "取消關注", + "followees": "正在關注", + "followers": "關注者", + "following": "正在關注!", + "follows_you": "關注了你!", + "hidden": "已隱藏", + "mention": "提及", + "message": "消息", + "mute": "靜音", + "muted": "已靜音", + "report": "報告", + "statuses": "發文", + "unsubscribe": "退訂", + "unblock": "取消封鎖", + "unblock_progress": "取消封鎖中…", + "block_progress": "封鎖中…", + "unmute": "取消靜音", + "unmute_progress": "取消靜音中…", + "hide_repeats": "隱藏轉發", + "show_repeats": "顯示轉發" + }, + "user_profile": { + "timeline_title": "用戶時間線", + "profile_does_not_exist": "抱歉,此個人資料不存在。", + "profile_loading_error": "抱歉,載入個人資料時出錯。" + }, + "user_reporting": { + "title": "報告 {0}", + "add_comment_description": "此報告會發送給你的實例管理員。你可以在下面提供更多詳細信息解釋報告的緣由:", + "forward_to": "轉發 {0}", + "submit": "提交", + "generic_error": "當處理你的請求時,發生了一個錯誤。", + "additional_comments": "其它評論", + "forward_description": "這個賬號是從另外一個服務器。同時發送一個報告到那裡?" + }, + "who_to_follow": { + "more": "更多", + "who_to_follow": "推薦關注" + }, + "tool_tip": { + "media_upload": "上傳多媒體", + "repeat": "轉發", + "favorite": "喜歡", + "add_reaction": "添加互動", + "reply": "回覆", + "user_settings": "用戶設置", + "accept_follow_request": "接受關注請求", + "reject_follow_request": "拒絕關注請求", + "bookmark": "書籤" + }, + "upload": { + "file_size_units": { + "B": "B", + "KiB": "KiB", + "TiB": "TiB", + "MiB": "MiB", + "GiB": "GiB" + }, + "error": { + "base": "上傳失敗。", + "file_too_big": "文件太大[{filesize} {filesizeunit} / {allowedsize} {allowedsizeunit}]", + "default": "稍後再試" + } + }, + "search": { + "people": "人", + "hashtags": "標籤", + "person_talking": "{count} 人正在討論", + "people_talking": "{count} 人正在討論", + "no_results": "沒有搜索結果" + }, + "password_reset": { + "forgot_password": "忘記密碼了?", + "password_reset": "重置密碼", + "instruction": "輸入你的電郵地址或者用戶名,我們將發送一個鏈接到你的郵箱,用於重置密碼。", + "placeholder": "你的電郵地址或者用戶名", + "check_email": "檢查你的郵箱,會有一個鏈接用於重置密碼。", + "return_home": "回到首頁", + "too_many_requests": "你觸發了嘗試的限制,請稍後再試。", + "password_reset_disabled": "密碼重置已經被禁用。請聯繫你的實例管理員。", + "password_reset_required": "您必須重置密碼才能登陸。", + "password_reset_required_but_mailer_is_disabled": "您必須重置密碼,但是密碼重置被禁用了。請聯繫您所在實例的管理員。" + } +} diff --git a/src/main.js b/src/main.js index 0a898022..90ee2887 100644 --- a/src/main.js +++ b/src/main.js @@ -33,6 +33,8 @@ import VueClickOutside from 'v-click-outside' import PortalVue from 'portal-vue' import VBodyScrollLock from './directives/body_scroll_lock' +import { FontAwesomeIcon, FontAwesomeLayers } from '@fortawesome/vue-fontawesome' + import afterStoreSetup from './boot/after_store.js' const currentLocale = (window.navigator.language || 'en').split('-')[0] @@ -45,6 +47,9 @@ Vue.use(VueClickOutside) Vue.use(PortalVue) Vue.use(VBodyScrollLock) +Vue.component('FAIcon', FontAwesomeIcon) +Vue.component('FALayers', FontAwesomeLayers) + const i18n = new VueI18n({ // By default, use the browser locale, we will update it if neccessary locale: 'en', diff --git a/src/modules/api.js b/src/modules/api.js index 5e213f0d..08485a30 100644 --- a/src/modules/api.js +++ b/src/modules/api.js @@ -20,7 +20,7 @@ const api = { state.fetchers[fetcherName] = fetcher }, removeFetcher (state, { fetcherName, fetcher }) { - window.clearInterval(fetcher) + state.fetchers[fetcherName].stop() delete state.fetchers[fetcherName] }, setWsToken (state, token) { @@ -72,13 +72,21 @@ const api = { showImmediately: timelineData.visibleStatuses.length === 0, timeline: 'friends' }) + } else if (message.event === 'delete') { + dispatch('deleteStatusById', message.id) } else if (message.event === 'pleroma:chat_update') { - dispatch('addChatMessages', { - chatId: message.chatUpdate.id, - messages: [message.chatUpdate.lastMessage] - }) - dispatch('updateChat', { chat: message.chatUpdate }) - maybeShowChatNotification(store, message.chatUpdate) + // The setTimeout wrapper is a temporary band-aid to avoid duplicates for the user's own messages when doing optimistic sending. + // The cause of the duplicates is the WS event arriving earlier than the HTTP response. + // This setTimeout wrapper can be removed once the commit `8e41baff` is in the stable Pleroma release. + // (`8e41baff` adds the idempotency key to the chat message entity, which PleromaFE uses when it's available, and it makes this artificial delay unnecessary). + setTimeout(() => { + dispatch('addChatMessages', { + chatId: message.chatUpdate.id, + messages: [message.chatUpdate.lastMessage] + }) + dispatch('updateChat', { chat: message.chatUpdate }) + maybeShowChatNotification(store, message.chatUpdate) + }, 100) } } ) diff --git a/src/modules/chats.js b/src/modules/chats.js index c5715c14..0a373d88 100644 --- a/src/modules/chats.js +++ b/src/modules/chats.js @@ -3,6 +3,7 @@ import { find, omitBy, orderBy, sumBy } from 'lodash' import chatService from '../services/chat_service/chat_service.js' import { parseChat, parseChatMessage } from '../services/entity_normalizer/entity_normalizer.service.js' import { maybeShowChatNotification } from '../services/chat_utils/chat_utils.js' +import { promiseInterval } from '../services/promise_interval/promise_interval.js' const emptyChatList = () => ({ data: [], @@ -15,7 +16,8 @@ const defaultState = { openedChats: {}, openedChatMessageServices: {}, fetcher: undefined, - currentChatId: null + currentChatId: null, + lastReadMessageId: null } const getChatById = (state, id) => { @@ -42,12 +44,10 @@ const chats = { actions: { // Chat list startFetchingChats ({ dispatch, commit }) { - const fetcher = () => { - dispatch('fetchChats', { latest: true }) - } + const fetcher = () => dispatch('fetchChats', { latest: true }) fetcher() commit('setChatListFetcher', { - fetcher: () => setInterval(() => { fetcher() }, 5000) + fetcher: () => promiseInterval(fetcher, 5000) }) }, stopFetchingChats ({ commit }) { @@ -93,9 +93,14 @@ const chats = { commit('setCurrentChatFetcher', { fetcher: undefined }) }, readChat ({ rootState, commit, dispatch }, { id, lastReadId }) { + const isNewMessage = rootState.chats.lastReadMessageId !== lastReadId + dispatch('resetChatNewMessageCount') - commit('readChat', { id }) - rootState.api.backendInteractor.readChat({ id, lastReadId }) + commit('readChat', { id, lastReadId }) + + if (isNewMessage) { + rootState.api.backendInteractor.readChat({ id, lastReadId }) + } }, deleteChatMessage ({ rootState, commit }, value) { rootState.api.backendInteractor.deleteChatMessage(value) @@ -107,20 +112,23 @@ const chats = { }, clearOpenedChats ({ rootState, commit, dispatch, rootGetters }) { commit('clearOpenedChats', { commit }) + }, + handleMessageError ({ commit }, value) { + commit('handleMessageError', { commit, ...value }) } }, mutations: { setChatListFetcher (state, { commit, fetcher }) { const prevFetcher = state.chatListFetcher if (prevFetcher) { - clearInterval(prevFetcher) + prevFetcher.stop() } state.chatListFetcher = fetcher && fetcher() }, setCurrentChatFetcher (state, { fetcher }) { const prevFetcher = state.fetcher if (prevFetcher) { - clearInterval(prevFetcher) + prevFetcher.stop() } state.fetcher = fetcher && fetcher() }, @@ -209,11 +217,16 @@ const chats = { } } }, - readChat (state, { id }) { + readChat (state, { id, lastReadId }) { + state.lastReadMessageId = lastReadId const chat = getChatById(state, id) if (chat) { chat.unread = 0 } + }, + handleMessageError (state, { chatId, fakeId, isRetry }) { + const chatMessageService = state.openedChatMessageServices[chatId] + chatService.handleMessageError(chatMessageService, fakeId, isRetry) } } } diff --git a/src/modules/config.js b/src/modules/config.js index 409d77a4..444b8ec7 100644 --- a/src/modules/config.js +++ b/src/modules/config.js @@ -65,7 +65,8 @@ export const defaultState = { useContainFit: false, greentext: undefined, // instance default hidePostStats: undefined, // instance default - hideUserStats: undefined // instance default + hideUserStats: undefined, // instance default + virtualScrolling: undefined // instance default } // caching the instance default properties diff --git a/src/modules/instance.js b/src/modules/instance.js index 3fe3bbf3..5f7bf0ec 100644 --- a/src/modules/instance.js +++ b/src/modules/instance.js @@ -27,9 +27,10 @@ const defaultState = { hideSitename: false, hideUserStats: false, loginMethod: 'password', - logo: '/static/logo.png', + logo: '/static/logo.svg', logoMargin: '.2em', logoMask: true, + logoLeft: false, minimalScopesMode: false, nsfwCensorImage: undefined, postContentType: 'text/plain', @@ -41,6 +42,7 @@ const defaultState = { sidebarRight: false, subjectLineBehavior: 'email', theme: 'pleroma-dark', + virtualScrolling: true, // Nasty stuff customEmoji: [], diff --git a/src/modules/statuses.js b/src/modules/statuses.js index e108b2a7..e673141d 100644 --- a/src/modules/statuses.js +++ b/src/modules/statuses.js @@ -568,6 +568,9 @@ export const mutations = { updateStatusWithPoll (state, { id, poll }) { const status = state.allStatusesObject[id] status.poll = poll + }, + setVirtualHeight (state, { statusId, height }) { + state.allStatusesObject[statusId].virtualHeight = height } } @@ -608,6 +611,10 @@ const statuses = { commit('setDeleted', { status }) apiService.deleteStatus({ id: status.id, credentials: rootState.users.currentUser.credentials }) }, + deleteStatusById ({ rootState, commit }, id) { + const status = rootState.statuses.allStatusesObject[id] + commit('setDeleted', { status }) + }, markStatusesAsDeleted ({ commit }, condition) { commit('setManyDeleted', condition) }, @@ -753,6 +760,9 @@ const statuses = { store.commit('addNewStatuses', { statuses: data.statuses }) return data }) + }, + setVirtualHeight ({ commit }, { statusId, height }) { + commit('setVirtualHeight', { statusId, height }) } }, mutations diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js index da519001..22b5e8ba 100644 --- a/src/services/api/api.service.js +++ b/src/services/api/api.service.js @@ -3,6 +3,7 @@ import { parseStatus, parseUser, parseNotification, parseAttachment, parseChat, import { RegistrationError, StatusCodeError } from '../errors/errors' /* eslint-env browser */ +const MUTES_IMPORT_URL = '/api/pleroma/mutes_import' const BLOCKS_IMPORT_URL = '/api/pleroma/blocks_import' const FOLLOW_IMPORT_URL = '/api/pleroma/follow_import' const DELETE_ACCOUNT_URL = '/api/pleroma/delete_account' @@ -128,7 +129,11 @@ const promisedRequest = ({ method, url, params, payload, credentials, headers = return reject(new StatusCodeError(response.status, json, { url, options }, response)) } return resolve(json) - })) + }) + .catch((error) => { + return reject(new StatusCodeError(response.status, error, { url, options }, response)) + }) + ) }) } @@ -539,8 +544,10 @@ const fetchTimeline = ({ const queryString = map(params, (param) => `${param[0]}=${param[1]}`).join('&') url += `?${queryString}` + let status = '' let statusText = '' + let pagination = {} return fetch(url, { headers: authHeaders(credentials) }) .then((data) => { @@ -710,6 +717,17 @@ const setMediaDescription = ({ id, description, credentials }) => { }).then((data) => parseAttachment(data)) } +const importMutes = ({ file, credentials }) => { + const formData = new FormData() + formData.append('list', file) + return fetch(MUTES_IMPORT_URL, { + body: formData, + method: 'POST', + headers: authHeaders(credentials) + }) + .then((response) => response.ok) +} + const importBlocks = ({ file, credentials }) => { const formData = new FormData() formData.append('list', file) @@ -1196,7 +1214,7 @@ const chatMessages = ({ id, credentials, maxId, sinceId, limit = 20 }) => { }) } -const sendChatMessage = ({ id, content, mediaId = null, credentials }) => { +const sendChatMessage = ({ id, content, mediaId = null, idempotencyKey, credentials }) => { const payload = { 'content': content } @@ -1205,11 +1223,18 @@ const sendChatMessage = ({ id, content, mediaId = null, credentials }) => { payload['media_id'] = mediaId } + const headers = {} + + if (idempotencyKey) { + headers['idempotency-key'] = idempotencyKey + } + return promisedRequest({ url: PLEROMA_CHAT_MESSAGES_URL(id), method: 'POST', payload: payload, - credentials + credentials, + headers }) } @@ -1280,6 +1305,7 @@ const apiService = { getCaptcha, updateProfileImages, updateProfile, + importMutes, importBlocks, importFollows, deleteAccount, diff --git a/src/services/chat_service/chat_service.js b/src/services/chat_service/chat_service.js index 95c69482..1fc4e390 100644 --- a/src/services/chat_service/chat_service.js +++ b/src/services/chat_service/chat_service.js @@ -3,9 +3,10 @@ import _ from 'lodash' const empty = (chatId) => { return { idIndex: {}, + idempotencyKeyIndex: {}, messages: [], newMessageCount: 0, - lastSeenTimestamp: 0, + lastSeenMessageId: '0', chatId: chatId, minId: undefined, maxId: undefined @@ -13,10 +14,20 @@ const empty = (chatId) => { } const clear = (storage) => { - storage.idIndex = {} - storage.messages.splice(0, storage.messages.length) + const failedMessageIds = [] + + for (const message of storage.messages) { + if (message.error) { + failedMessageIds.push(message.id) + } else { + delete storage.idIndex[message.id] + delete storage.idempotencyKeyIndex[message.id] + } + } + + storage.messages = storage.messages.filter(m => failedMessageIds.includes(m.id)) storage.newMessageCount = 0 - storage.lastSeenTimestamp = 0 + storage.lastSeenMessageId = '0' storage.minId = undefined storage.maxId = undefined } @@ -37,6 +48,25 @@ const deleteMessage = (storage, messageId) => { } } +const handleMessageError = (storage, fakeId, isRetry) => { + if (!storage) { return } + const fakeMessage = storage.idIndex[fakeId] + if (fakeMessage) { + fakeMessage.error = true + fakeMessage.pending = false + if (!isRetry) { + // Ensure the failed message doesn't stay at the bottom of the list. + const lastPersistedMessage = _.orderBy(storage.messages, ['pending', 'id'], ['asc', 'desc'])[0] + if (lastPersistedMessage) { + const oldId = fakeMessage.id + fakeMessage.id = `${lastPersistedMessage.id}-${new Date().getTime()}` + storage.idIndex[fakeMessage.id] = fakeMessage + delete storage.idIndex[oldId] + } + } + } +} + const add = (storage, { messages: newMessages, updateMaxId = true }) => { if (!storage) { return } for (let i = 0; i < newMessages.length; i++) { @@ -45,7 +75,25 @@ const add = (storage, { messages: newMessages, updateMaxId = true }) => { // sanity check if (message.chat_id !== storage.chatId) { return } - if (!storage.minId || message.id < storage.minId) { + if (message.fakeId) { + const fakeMessage = storage.idIndex[message.fakeId] + if (fakeMessage) { + // In case the same id exists (chat update before POST response) + // make sure to remove the older duplicate message. + if (storage.idIndex[message.id]) { + delete storage.idIndex[message.id] + storage.messages = storage.messages.filter(msg => msg.id !== message.id) + } + Object.assign(fakeMessage, message, { error: false }) + delete fakeMessage['fakeId'] + storage.idIndex[fakeMessage.id] = fakeMessage + delete storage.idIndex[message.fakeId] + + return + } + } + + if (!storage.minId || (!message.pending && message.id < storage.minId)) { storage.minId = message.id } @@ -55,20 +103,26 @@ const add = (storage, { messages: newMessages, updateMaxId = true }) => { } } - if (!storage.idIndex[message.id]) { - if (storage.lastSeenTimestamp < message.created_at) { + if (!storage.idIndex[message.id] && !isConfirmation(storage, message)) { + if (storage.lastSeenMessageId < message.id) { storage.newMessageCount++ } - storage.messages.push(message) storage.idIndex[message.id] = message + storage.messages.push(storage.idIndex[message.id]) + storage.idempotencyKeyIndex[message.idempotency_key] = true } } } +const isConfirmation = (storage, message) => { + if (!message.idempotency_key) return + return storage.idempotencyKeyIndex[message.idempotency_key] +} + const resetNewMessageCount = (storage) => { if (!storage) { return } storage.newMessageCount = 0 - storage.lastSeenTimestamp = new Date() + storage.lastSeenMessageId = storage.maxId } // Inserts date separators and marks the head and tail if it's the chain of messages made by the same user @@ -76,7 +130,7 @@ const getView = (storage) => { if (!storage) { return [] } const result = [] - const messages = _.sortBy(storage.messages, ['id', 'desc']) + const messages = _.orderBy(storage.messages, ['pending', 'id'], ['asc', 'asc']) const firstMessage = messages[0] let previousMessage = messages[messages.length - 1] let currentMessageChainId @@ -148,7 +202,8 @@ const ChatService = { getView, deleteMessage, resetNewMessageCount, - clear + clear, + handleMessageError } export default ChatService diff --git a/src/services/chat_utils/chat_utils.js b/src/services/chat_utils/chat_utils.js index 583438f7..de6e0625 100644 --- a/src/services/chat_utils/chat_utils.js +++ b/src/services/chat_utils/chat_utils.js @@ -3,7 +3,7 @@ import { showDesktopNotification } from '../desktop_notification_utils/desktop_n export const maybeShowChatNotification = (store, chat) => { if (!chat.lastMessage) return if (store.rootState.chats.currentChatId === chat.id && !document.hidden) return - if (store.rootState.users.currentUser.id === chat.lastMessage.account.id) return + if (store.rootState.users.currentUser.id === chat.lastMessage.account_id) return const opts = { tag: chat.lastMessage.id, @@ -18,3 +18,24 @@ export const maybeShowChatNotification = (store, chat) => { showDesktopNotification(store.rootState, opts) } + +export const buildFakeMessage = ({ content, chatId, attachments, userId, idempotencyKey }) => { + const fakeMessage = { + content, + chat_id: chatId, + created_at: new Date(), + id: `${new Date().getTime()}`, + attachments: attachments, + account_id: userId, + idempotency_key: idempotencyKey, + emojis: [], + pending: true, + isNormalized: true + } + + if (attachments[0]) { + fakeMessage.attachment = attachments[0] + } + + return fakeMessage +} diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js index 1884478a..9d09b8d0 100644 --- a/src/services/entity_normalizer/entity_normalizer.service.js +++ b/src/services/entity_normalizer/entity_normalizer.service.js @@ -429,6 +429,9 @@ export const parseChatMessage = (message) => { } else { output.attachments = [] } + output.pending = !!message.pending + output.error = false + output.idempotency_key = message.idempotency_key output.isNormalized = true return output } diff --git a/src/services/follow_request_fetcher/follow_request_fetcher.service.js b/src/services/follow_request_fetcher/follow_request_fetcher.service.js index 93fac9bc..74af4081 100644 --- a/src/services/follow_request_fetcher/follow_request_fetcher.service.js +++ b/src/services/follow_request_fetcher/follow_request_fetcher.service.js @@ -1,4 +1,5 @@ import apiService from '../api/api.service.js' +import { promiseInterval } from '../promise_interval/promise_interval.js' const fetchAndUpdate = ({ store, credentials }) => { return apiService.fetchFollowRequests({ credentials }) @@ -10,9 +11,9 @@ const fetchAndUpdate = ({ store, credentials }) => { } const startFetching = ({ credentials, store }) => { - fetchAndUpdate({ credentials, store }) const boundFetchAndUpdate = () => fetchAndUpdate({ credentials, store }) - return setInterval(boundFetchAndUpdate, 10000) + boundFetchAndUpdate() + return promiseInterval(boundFetchAndUpdate, 10000) } const followRequestFetcher = { diff --git a/src/services/notifications_fetcher/notifications_fetcher.service.js b/src/services/notifications_fetcher/notifications_fetcher.service.js index 133e913f..6ff7d9df 100644 --- a/src/services/notifications_fetcher/notifications_fetcher.service.js +++ b/src/services/notifications_fetcher/notifications_fetcher.service.js @@ -1,4 +1,5 @@ import apiService from '../api/api.service.js' +import { promiseInterval } from '../promise_interval/promise_interval.js' const update = ({ store, notifications, older }) => { store.dispatch('setNotificationsError', { value: false }) @@ -42,6 +43,7 @@ const fetchAndUpdate = ({ store, credentials, older = false }) => { args['since'] = Math.max(...readNotifsIds) fetchNotifications({ store, args, older }) } + return result } } @@ -56,13 +58,13 @@ const fetchNotifications = ({ store, args, older }) => { } const startFetching = ({ credentials, store }) => { - fetchAndUpdate({ credentials, store }) - const boundFetchAndUpdate = () => fetchAndUpdate({ credentials, store }) // Initially there's set flag to silence all desktop notifications so // that there won't spam of them when user just opened up the FE we // reset that flag after a while to show new notifications once again. setTimeout(() => store.dispatch('setNotificationsSilence', false), 10000) - return setInterval(boundFetchAndUpdate, 10000) + const boundFetchAndUpdate = () => fetchAndUpdate({ credentials, store }) + boundFetchAndUpdate() + return promiseInterval(boundFetchAndUpdate, 10000) } const notificationsFetcher = { diff --git a/src/services/promise_interval/promise_interval.js b/src/services/promise_interval/promise_interval.js new file mode 100644 index 00000000..0c0a66a0 --- /dev/null +++ b/src/services/promise_interval/promise_interval.js @@ -0,0 +1,34 @@ + +// promiseInterval - replacement for setInterval for promises, starts counting +// the interval only after a promise is done instead of immediately. +// - promiseCall is a function that returns a promise, it's called the first +// time after the first interval. +// - interval is the interval delay in ms. + +export const promiseInterval = (promiseCall, interval) => { + let stopped = false + let timeout = null + + const func = () => { + const promise = promiseCall() + // something unexpected happened and promiseCall did not + // return a promise, abort the loop. + if (!(promise && promise.finally)) { + console.warn('promiseInterval: promise call did not return a promise, stopping interval.') + return + } + promise.finally(() => { + if (stopped) return + timeout = window.setTimeout(func, interval) + }) + } + + const stopFetcher = () => { + stopped = true + window.clearTimeout(timeout) + } + + timeout = window.setTimeout(func, interval) + + return { stop: stopFetcher } +} diff --git a/src/services/timeline_fetcher/timeline_fetcher.service.js b/src/services/timeline_fetcher/timeline_fetcher.service.js index d0cddf84..72ea4890 100644 --- a/src/services/timeline_fetcher/timeline_fetcher.service.js +++ b/src/services/timeline_fetcher/timeline_fetcher.service.js @@ -1,6 +1,7 @@ import { camelCase } from 'lodash' import apiService from '../api/api.service.js' +import { promiseInterval } from '../promise_interval/promise_interval.js' const update = ({ store, statuses, timeline, showImmediately, userId, pagination }) => { const ccTimeline = camelCase(timeline) @@ -71,8 +72,9 @@ const startFetching = ({ timeline = 'friends', credentials, store, userId = fals const showImmediately = timelineData.visibleStatuses.length === 0 timelineData.userId = userId fetchAndUpdate({ timeline, credentials, store, showImmediately, userId, tag }) - const boundFetchAndUpdate = () => fetchAndUpdate({ timeline, credentials, store, userId, tag }) - return setInterval(boundFetchAndUpdate, 10000) + const boundFetchAndUpdate = () => + fetchAndUpdate({ timeline, credentials, store, userId, tag }) + return promiseInterval(boundFetchAndUpdate, 10000) } const timelineFetcher = { fetchAndUpdate, |
