diff options
Diffstat (limited to 'src')
135 files changed, 6280 insertions, 1329 deletions
@@ -9,7 +9,9 @@ import ChatPanel from './components/chat_panel/chat_panel.vue' import MediaModal from './components/media_modal/media_modal.vue' import SideDrawer from './components/side_drawer/side_drawer.vue' import MobilePostStatusModal from './components/mobile_post_status_modal/mobile_post_status_modal.vue' -import { unseenNotificationsFromStore } from './services/notification_utils/notification_utils' +import MobileNav from './components/mobile_nav/mobile_nav.vue' +import UserReportingModal from './components/user_reporting_modal/user_reporting_modal.vue' +import { windowWidth } from './services/window_utils/window_utils' export default { name: 'app', @@ -24,7 +26,9 @@ export default { ChatPanel, MediaModal, SideDrawer, - MobilePostStatusModal + MobilePostStatusModal, + MobileNav, + UserReportingModal }, data: () => ({ mobileActivePanel: 'timeline', @@ -40,6 +44,10 @@ export default { created () { // Load the locale from the storage this.$i18n.locale = this.$store.state.config.interfaceLanguage + window.addEventListener('resize', this.updateMobileState) + }, + destroyed () { + window.removeEventListener('resize', this.updateMobileState) }, computed: { currentUser () { return this.$store.state.users.currentUser }, @@ -82,13 +90,8 @@ export default { chat () { return this.$store.state.chat.channel.state === 'joined' }, suggestionsEnabled () { return this.$store.state.instance.suggestionsEnabled }, showInstanceSpecificPanel () { return this.$store.state.instance.showInstanceSpecificPanel }, - unseenNotifications () { - return unseenNotificationsFromStore(this.$store) - }, - unseenNotificationsCount () { - return this.unseenNotifications.length - }, - showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel } + showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel }, + isMobileLayout () { return this.$store.state.interface.mobileLayout } }, methods: { scrollToTop () { @@ -101,8 +104,12 @@ export default { onFinderToggled (hidden) { this.finderHidden = hidden }, - toggleMobileSidebar () { - this.$refs.sideDrawer.toggleDrawer() + updateMobileState () { + const mobileLayout = windowWidth() <= 800 + const changed = mobileLayout !== this.isMobileLayout + if (changed) { + this.$store.dispatch('setMobileLayout', mobileLayout) + } } } } diff --git a/src/App.scss b/src/App.scss index ae068e4f..52a786ad 100644 --- a/src/App.scss +++ b/src/App.scss @@ -101,6 +101,14 @@ button { background-color: $fallback--bg; background-color: var(--bg, $fallback--bg) } + + &.danger { + // TODO: add better color variable + color: $fallback--text; + color: var(--alertErrorPanelText, $fallback--text); + background-color: $fallback--alertError; + background-color: var(--alertError, $fallback--alertError); + } } label.select { @@ -371,6 +379,7 @@ main-router { .panel-heading { display: flex; + flex: none; border-radius: $fallback--panelRadius $fallback--panelRadius 0 0; border-radius: var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius) 0 0; background-size: cover; @@ -484,24 +493,6 @@ nav { } } -.menu-button { - display: none; - position: relative; -} - -.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); -} - .fade-enter-active, .fade-leave-active { transition: opacity .2s } @@ -530,20 +521,6 @@ nav { display: none; } -.panel-switcher { - display: none; - width: 100%; - height: 46px; - - button { - display: block; - flex: 1; - max-height: 32px; - margin: 0.5em; - padding: 0.5em; - } -} - @media all and (min-width: 800px) { body { overflow-y: scroll; @@ -648,21 +625,6 @@ nav { text-align: right; } -.visibility-tray { - font-size: 1.2em; - padding: 3px; - cursor: pointer; - - .selected { - color: $fallback--lightText; - color: var(--lightText, $fallback--lightText); - } - - div { - padding-top: 5px; - } -} - .visibility-notice { padding: .5em; border: 1px solid $fallback--faint; @@ -671,6 +633,19 @@ nav { border-radius: var(--inputRadius, $fallback--inputRadius); } +.notice-dismissible { + padding-right: 4rem; + position: relative; + + .dismiss { + position: absolute; + top: 0; + right: 0; + padding: .5em; + color: inherit; + } +} + @keyframes modal-background-fadein { from { background-color: rgba(0, 0, 0, 0); @@ -750,6 +725,70 @@ nav { } } +.setting-item { + border-bottom: 2px solid var(--fg, $fallback--fg); + margin: 1em 1em 1.4em; + padding-bottom: 1.4em; + + > div { + margin-bottom: .5em; + &:last-child { + margin-bottom: 0; + } + } + + &:last-child { + border-bottom: none; + padding-bottom: 0; + margin-bottom: 1em; + } + + select { + min-width: 10em; + } + + + textarea { + width: 100%; + max-width: 100%; + height: 100px; + } + + .unavailable, + .unavailable i { + color: var(--cRed, $fallback--cRed); + color: $fallback--cRed; + } + + .btn { + min-height: 28px; + min-width: 10em; + padding: 0 2em; + } + + .number-input { + max-width: 6em; + } +} +.select-multiple { + display: flex; + .option-list { + margin: 0; + padding-left: .5em; + } +} +.setting-list, +.option-list{ + list-style-type: none; + padding-left: 2em; + li { + margin-bottom: 0.5em; + } + .suboptions { + margin-top: 0.3em + } +} + .login-hint { text-align: center; @@ -817,4 +856,4 @@ nav { background-color: var(--lightBg, $fallback--fg); } } -}
\ No newline at end of file +} diff --git a/src/App.vue b/src/App.vue index 4fff3d1d..769e075d 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,17 +1,14 @@ <template> <div id="app" v-bind:style="bgAppStyle"> <div class="app-bg-wrapper" v-bind:style="bgStyle"></div> - <nav class='nav-bar container' @click="scrollToTop()" id="nav"> + <MobileNav v-if="isMobileLayout" /> + <nav v-else class='nav-bar container' @click="scrollToTop()" id="nav"> <div class='logo' :style='logoBgStyle'> <div class='mask' :style='logoMaskStyle'></div> <img :src='logo' :style='logoStyle'> </div> <div class='inner-nav'> <div class='item'> - <a href="#" class="menu-button" @click.stop.prevent="toggleMobileSidebar()"> - <i class="button-icon icon-menu"></i> - <div class="alert-dot" v-if="unseenNotificationsCount"></div> - </a> <router-link class="site-name" :to="{ name: 'root' }" active-class="home">{{sitename}}</router-link> </div> <div class='item right'> @@ -21,18 +18,19 @@ </div> </div> </nav> - <div v-if="" class="container" id="content"> - <side-drawer ref="sideDrawer" :logout="logout"></side-drawer> + <div class="container" id="content"> <div class="sidebar-flexer mobile-hidden"> <div class="sidebar-bounds"> <div class="sidebar-scroller"> <div class="sidebar"> <user-panel></user-panel> - <nav-panel></nav-panel> - <instance-specific-panel v-if="showInstanceSpecificPanel"></instance-specific-panel> - <features-panel v-if="!currentUser && showFeaturesPanel"></features-panel> - <who-to-follow-panel v-if="currentUser && suggestionsEnabled"></who-to-follow-panel> - <notifications v-if="currentUser"></notifications> + <div v-if="!isMobileLayout"> + <nav-panel></nav-panel> + <instance-specific-panel v-if="showInstanceSpecificPanel"></instance-specific-panel> + <features-panel v-if="!currentUser && showFeaturesPanel"></features-panel> + <who-to-follow-panel v-if="currentUser && suggestionsEnabled"></who-to-follow-panel> + <notifications v-if="currentUser"></notifications> + </div> </div> </div> </div> @@ -50,7 +48,8 @@ <media-modal></media-modal> </div> <chat-panel :floating="true" v-if="currentUser && chat" class="floating-chat mobile-hidden"></chat-panel> - <MobilePostStatusModal /> + <UserReportingModal /> + <portal-target name="modal" /> </div> </template> diff --git a/src/boot/after_store.js b/src/boot/after_store.js index f5add8ad..c271d413 100644 --- a/src/boot/after_store.js +++ b/src/boot/after_store.js @@ -1,20 +1,23 @@ import Vue from 'vue' import VueRouter from 'vue-router' import routes from './routes' - import App from '../App.vue' +import { windowWidth } from '../services/window_utils/window_utils' +import { getOrCreateApp, getClientToken } from '../services/new_api/oauth.js' +import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js' const getStatusnetConfig = async ({ store }) => { try { const res = await window.fetch('/api/statusnet/config.json') if (res.ok) { const data = await res.json() - const { name, closed: registrationClosed, textlimit, uploadlimit, server, vapidPublicKey } = data.site + const { name, closed: registrationClosed, textlimit, uploadlimit, server, vapidPublicKey, safeDMMentionsEnabled } = data.site store.dispatch('setInstanceOption', { name: 'name', value: name }) store.dispatch('setInstanceOption', { name: 'registrationOpen', value: (registrationClosed === '0') }) store.dispatch('setInstanceOption', { name: 'textlimit', value: parseInt(textlimit) }) store.dispatch('setInstanceOption', { name: 'server', value: server }) + store.dispatch('setInstanceOption', { name: 'safeDM', value: safeDMMentionsEnabled !== '0' }) // TODO: default values for this stuff, added if to not make it break on // my dev config out of the box. @@ -91,15 +94,15 @@ const setSettings = async ({ apiConfig, staticConfig, store }) => { ? 0 : config.logoMargin }) + store.commit('authFlow/setInitialStrategy', config.loginMethod) copyInstanceOption('redirectRootNoLogin') copyInstanceOption('redirectRootLogin') copyInstanceOption('showInstanceSpecificPanel') - copyInstanceOption('scopeOptionsEnabled') + copyInstanceOption('minimalScopesMode') copyInstanceOption('formattingOptionsEnabled') copyInstanceOption('hideMutedPosts') copyInstanceOption('collapseMessageWithSubject') - copyInstanceOption('loginMethod') copyInstanceOption('scopeCopy') copyInstanceOption('subjectLineBehavior') copyInstanceOption('postContentType') @@ -170,9 +173,10 @@ const getCustomEmoji = async ({ store }) => { try { const res = await window.fetch('/api/pleroma/emoji.json') if (res.ok) { - const values = await res.json() + const result = await res.json() + const values = Array.isArray(result) ? Object.assign({}, ...result) : result const emoji = Object.keys(values).map((key) => { - return { shortcode: key, image_url: values[key] } + return { shortcode: key, image_url: values[key].image_url || values[key] } }) store.dispatch('setInstanceOption', { name: 'customEmoji', value: emoji }) store.dispatch('setInstanceOption', { name: 'pleromaBackend', value: true }) @@ -186,6 +190,17 @@ const getCustomEmoji = async ({ store }) => { } } +const getAppSecret = async ({ store }) => { + const { state, commit } = store + const { oauth, instance } = state + return getOrCreateApp({ ...oauth, instance: instance.server, commit }) + .then((app) => getClientToken({ ...app, instance: instance.server })) + .then((token) => { + commit('setAppToken', token.access_token) + commit('setBackendInteractor', backendInteractorService(store.getters.getToken())) + }) +} + const getNodeInfo = async ({ store }) => { try { const res = await window.fetch('/nodeinfo/2.0.json') @@ -210,6 +225,7 @@ const getNodeInfo = async ({ store }) => { const frontendVersion = window.___pleromafe_commit_hash store.dispatch('setInstanceOption', { name: 'frontendVersion', value: frontendVersion }) + store.dispatch('setInstanceOption', { name: 'tagPolicyAvailable', value: metadata.federation.mrf_policies.includes('TagPolicy') }) } else { throw (res) } @@ -219,6 +235,28 @@ const getNodeInfo = async ({ store }) => { } } +const setConfig = async ({ store }) => { + // apiConfig, staticConfig + const configInfos = await Promise.all([getStatusnetConfig({ store }), getStaticConfig()]) + const apiConfig = configInfos[0] + const staticConfig = configInfos[1] + + await setSettings({ store, apiConfig, staticConfig }).then(getAppSecret({ store })) +} + +const checkOAuthToken = async ({ store }) => { + return new Promise(async (resolve, reject) => { + if (store.getters.getUserToken()) { + try { + await store.dispatch('loginUser', store.getters.getUserToken()) + } catch (e) { + console.log(e) + } + } + resolve() + }) +} + const afterStoreSetup = async ({ store, i18n }) => { if (store.state.config.customTheme) { // This is a hack to deal with async loading of config.json and themes @@ -230,19 +268,19 @@ const afterStoreSetup = async ({ store, i18n }) => { }) } - const apiConfig = await getStatusnetConfig({ store }) - const staticConfig = await getStaticConfig() - await setSettings({ store, apiConfig, staticConfig }) - await getTOS({ store }) - await getInstancePanel({ store }) - await getStaticEmoji({ store }) - await getCustomEmoji({ store }) - await getNodeInfo({ store }) - - // Now we have the server settings and can try logging in - if (store.state.oauth.token) { - await store.dispatch('loginUser', store.state.oauth.token) - } + const width = windowWidth() + store.dispatch('setMobileLayout', width <= 800) + + // Now we can try getting the server settings and logging in + await Promise.all([ + checkOAuthToken({ store }), + setConfig({ store }), + getTOS({ store }), + getInstancePanel({ store }), + getStaticEmoji({ store }), + getCustomEmoji({ store }), + getNodeInfo({ store }) + ]) const router = new VueRouter({ mode: 'history', diff --git a/src/boot/routes.js b/src/boot/routes.js index 7e54a98b..055a0aea 100644 --- a/src/boot/routes.js +++ b/src/boot/routes.js @@ -3,7 +3,7 @@ import PublicAndExternalTimeline from 'components/public_and_external_timeline/p import FriendsTimeline from 'components/friends_timeline/friends_timeline.vue' import TagTimeline from 'components/tag_timeline/tag_timeline.vue' import ConversationPage from 'components/conversation-page/conversation-page.vue' -import Mentions from 'components/mentions/mentions.vue' +import Interactions from 'components/interactions/interactions.vue' import DMs from 'components/dm_timeline/dm_timeline.vue' import UserProfile from 'components/user_profile/user_profile.vue' import Settings from 'components/settings/settings.vue' @@ -13,7 +13,7 @@ import FollowRequests from 'components/follow_requests/follow_requests.vue' import OAuthCallback from 'components/oauth_callback/oauth_callback.vue' import UserSearch from 'components/user_search/user_search.vue' import Notifications from 'components/notifications/notifications.vue' -import LoginForm from 'components/login_form/login_form.vue' +import AuthForm from 'components/auth_form/auth_form.js' import ChatPanel from 'components/chat_panel/chat_panel.vue' import WhoToFollow from 'components/who_to_follow/who_to_follow.vue' import About from 'components/about/about.vue' @@ -34,7 +34,7 @@ export default (store) => { { name: 'tag-timeline', path: '/tag/:tag', component: TagTimeline }, { name: 'conversation', path: '/notice/:id', component: ConversationPage, meta: { dontScroll: true } }, { name: 'external-user-profile', path: '/users/:id', component: UserProfile }, - { name: 'mentions', path: '/users/:username/mentions', component: Mentions }, + { name: 'interactions', path: '/users/:username/interactions', component: Interactions }, { name: 'dms', path: '/users/:username/dms', component: DMs }, { name: 'settings', path: '/settings', component: Settings }, { name: 'registration', path: '/registration', component: Registration }, @@ -42,7 +42,7 @@ export default (store) => { { name: 'friend-requests', path: '/friend-requests', component: FollowRequests }, { name: 'user-settings', path: '/user-settings', component: UserSettings }, { name: 'notifications', path: '/:username/notifications', component: Notifications }, - { name: 'login', path: '/login', component: LoginForm }, + { name: 'login', path: '/login', component: AuthForm }, { name: 'chat', path: '/chat', component: ChatPanel, props: () => ({ floating: false }) }, { name: 'oauth-callback', path: '/oauth-callback', component: OAuthCallback, props: (route) => ({ code: route.query.code }) }, { name: 'user-search', path: '/user-search', component: UserSearch, props: (route) => ({ query: route.query.query }) }, diff --git a/src/components/auth_form/auth_form.js b/src/components/auth_form/auth_form.js new file mode 100644 index 00000000..e9a6e2d5 --- /dev/null +++ b/src/components/auth_form/auth_form.js @@ -0,0 +1,26 @@ +import LoginForm from '../login_form/login_form.vue' +import MFARecoveryForm from '../mfa_form/recovery_form.vue' +import MFATOTPForm from '../mfa_form/totp_form.vue' +import { mapGetters } from 'vuex' + +const AuthForm = { + name: 'AuthForm', + render (createElement) { + return createElement('component', { is: this.authForm }) + }, + computed: { + authForm () { + if (this.requiredTOTP) { return 'MFATOTPForm' } + if (this.requiredRecovery) { return 'MFARecoveryForm' } + return 'LoginForm' + }, + ...mapGetters('authFlow', ['requiredTOTP', 'requiredRecovery']) + }, + components: { + MFARecoveryForm, + MFATOTPForm, + LoginForm + } +} + +export default AuthForm diff --git a/src/components/autosuggest/autosuggest.js b/src/components/autosuggest/autosuggest.js new file mode 100644 index 00000000..d4efe912 --- /dev/null +++ b/src/components/autosuggest/autosuggest.js @@ -0,0 +1,52 @@ +const debounceMilliseconds = 500 + +export default { + props: { + query: { // function to query results and return a promise + type: Function, + required: true + }, + filter: { // function to filter results in real time + type: Function + }, + placeholder: { + type: String, + default: 'Search...' + } + }, + data () { + return { + term: '', + timeout: null, + results: [], + resultsVisible: false + } + }, + computed: { + filtered () { + return this.filter ? this.filter(this.results) : this.results + } + }, + watch: { + term (val) { + this.fetchResults(val) + } + }, + methods: { + fetchResults (term) { + clearTimeout(this.timeout) + this.timeout = setTimeout(() => { + this.results = [] + if (term) { + this.query(term).then((results) => { this.results = results }) + } + }, debounceMilliseconds) + }, + onInputClick () { + this.resultsVisible = true + }, + onClickOutside () { + this.resultsVisible = false + } + } +} diff --git a/src/components/autosuggest/autosuggest.vue b/src/components/autosuggest/autosuggest.vue new file mode 100644 index 00000000..91657a2d --- /dev/null +++ b/src/components/autosuggest/autosuggest.vue @@ -0,0 +1,45 @@ +<template> + <div class="autosuggest" v-click-outside="onClickOutside"> + <input v-model="term" :placeholder="placeholder" @click="onInputClick" class="autosuggest-input" /> + <div class="autosuggest-results" v-if="resultsVisible && filtered.length > 0"> + <slot v-for="item in filtered" :item="item" /> + </div> + </div> +</template> + +<script src="./autosuggest.js"></script> + +<style lang="scss"> +@import '../../_variables.scss'; + +.autosuggest { + position: relative; + + &-input { + display: block; + width: 100%; + } + + &-results { + position: absolute; + left: 0; + top: 100%; + right: 0; + max-height: 400px; + background-color: $fallback--lightBg; + background-color: var(--lightBg, $fallback--lightBg); + border-style: solid; + border-width: 1px; + border-color: $fallback--border; + border-color: var(--border, $fallback--border); + border-radius: $fallback--inputRadius; + border-radius: var(--inputRadius, $fallback--inputRadius); + border-top-left-radius: 0; + border-top-right-radius: 0; + box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.6); + box-shadow: var(--panelShadow); + overflow-y: auto; + z-index: 1; + } +} +</style> diff --git a/src/components/avatar_list/avatar_list.js b/src/components/avatar_list/avatar_list.js new file mode 100644 index 00000000..9b6301b2 --- /dev/null +++ b/src/components/avatar_list/avatar_list.js @@ -0,0 +1,21 @@ +import UserAvatar from '../user_avatar/user_avatar.vue' +import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' + +const AvatarList = { + props: ['users'], + computed: { + slicedUsers () { + return this.users ? this.users.slice(0, 15) : [] + } + }, + components: { + UserAvatar + }, + methods: { + userProfileLink (user) { + return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames) + } + } +} + +export default AvatarList diff --git a/src/components/avatar_list/avatar_list.vue b/src/components/avatar_list/avatar_list.vue new file mode 100644 index 00000000..c0238570 --- /dev/null +++ b/src/components/avatar_list/avatar_list.vue @@ -0,0 +1,38 @@ +<template> + <div class="avatars"> + <router-link :to="userProfileLink(user)" class="avatars-item" v-for="user in slicedUsers"> + <UserAvatar :user="user" class="avatar-small" /> + </router-link> + </div> +</template> + +<script src="./avatar_list.js" ></script> + +<style lang="scss"> +@import '../../_variables.scss'; + +.avatars { + display: flex; + margin: 0; + padding: 0; + + // For hiding overflowing elements + flex-wrap: wrap; + height: 24px; + + .avatars-item { + margin: 0 0 5px 5px; + + &:first-child { + padding-left: 5px; + } + + .avatar-small { + border-radius: $fallback--avatarAltRadius; + border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius); + height: 24px; + width: 24px; + } + } +} +</style> diff --git a/src/components/basic_user_card/basic_user_card.vue b/src/components/basic_user_card/basic_user_card.vue index 8afe8b44..634d62b3 100644 --- a/src/components/basic_user_card/basic_user_card.vue +++ b/src/components/basic_user_card/basic_user_card.vue @@ -1,7 +1,11 @@ <template> <div class="basic-user-card"> <router-link :to="userProfileLink(user)"> - <UserAvatar class="avatar" @click.prevent.native="toggleUserExpanded" :src="user.profile_image_url"/> + <UserAvatar + class="avatar" + :user="user" + @click.prevent.native="toggleUserExpanded" + /> </router-link> <div class="basic-user-card-expanded-content" v-if="userExpanded"> <UserCard :user="user" :rounded="true" :bordered="true"/> @@ -24,19 +28,11 @@ <script src="./basic_user_card.js"></script> <style lang="scss"> -@import '../../_variables.scss'; - .basic-user-card { display: flex; flex: 1 0; margin: 0; - padding-top: 0.6em; - padding-right: 1em; - padding-bottom: 0.6em; - padding-left: 1em; - border-bottom: 1px solid; - border-bottom-color: $fallback--border; - border-bottom-color: var(--border, $fallback--border); + padding: 0.6em 1em; &-collapsed-content { margin-left: 0.7em; @@ -52,14 +48,15 @@ width: 16px; vertical-align: middle; } + } - &-value { - display: inline-block; - max-width: 100%; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - } + &-user-name-value, + &-screen-name { + display: inline-block; + max-width: 100%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; } &-expanded-content { diff --git a/src/components/checkbox/checkbox.vue b/src/components/checkbox/checkbox.vue new file mode 100644 index 00000000..4152b049 --- /dev/null +++ b/src/components/checkbox/checkbox.vue @@ -0,0 +1,75 @@ +<template> + <label class="checkbox"> + <input type="checkbox" :checked="checked" @change="$emit('change', $event.target.checked)" :indeterminate.prop="indeterminate"> + <i class="checkbox-indicator" /> + <span v-if="!!$slots.default"><slot></slot></span> + </label> +</template> + +<script> +export default { + model: { + prop: 'checked', + event: 'change' + }, + props: ['checked', 'indeterminate'] +} +</script> + +<style lang="scss"> +@import '../../_variables.scss'; + +.checkbox { + position: relative; + display: inline-block; + padding-left: 1.2em; + min-height: 1.2em; + + &-indicator::before { + position: absolute; + left: 0; + top: 0; + display: block; + content: '✔'; + transition: color 200ms; + width: 1.1em; + height: 1.1em; + border-radius: $fallback--checkboxRadius; + border-radius: var(--checkboxRadius, $fallback--checkboxRadius); + box-shadow: 0px 0px 2px black inset; + box-shadow: var(--inputShadow); + background-color: $fallback--fg; + background-color: var(--input, $fallback--fg); + vertical-align: top; + text-align: center; + line-height: 1.1em; + font-size: 1.1em; + color: transparent; + overflow: hidden; + box-sizing: border-box; + } + + input[type=checkbox] { + display: none; + + &:checked + .checkbox-indicator::before { + color: $fallback--text; + color: var(--text, $fallback--text); + } + + &:indeterminate + .checkbox-indicator::before { + content: '–'; + color: $fallback--text; + color: var(--text, $fallback--text); + } + + &:disabled + .checkbox-indicator::before { + opacity: .5; + } + } + + & > span { + margin-left: .5em; + } +} +</style> diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js index 69058bf6..b3074590 100644 --- a/src/components/conversation/conversation.js +++ b/src/components/conversation/conversation.js @@ -1,5 +1,4 @@ -import { reduce, filter, findIndex } from 'lodash' -import { set } from 'vue' +import { reduce, filter, findIndex, clone } from 'lodash' import Status from '../status/status.vue' const sortById = (a, b) => { @@ -36,14 +35,14 @@ const conversation = { data () { return { highlight: null, - expanded: false, - converationStatusIds: [] + expanded: false } }, props: [ 'statusoid', 'collapsable', - 'isPage' + 'isPage', + 'showPinned' ], created () { if (this.isPage) { @@ -54,15 +53,6 @@ const conversation = { status () { return this.statusoid }, - idsToShow () { - if (this.converationStatusIds.length > 0) { - return this.converationStatusIds - } else if (this.statusId) { - return [this.statusId] - } else { - return [] - } - }, statusId () { if (this.statusoid.retweeted_status) { return this.statusoid.retweeted_status.id @@ -70,6 +60,13 @@ const conversation = { return this.statusoid.id } }, + conversationId () { + if (this.statusoid.retweeted_status) { + return this.statusoid.retweeted_status.statusnet_conversation_id + } else { + return this.statusoid.statusnet_conversation_id + } + }, conversation () { if (!this.status) { return [] @@ -79,12 +76,7 @@ const conversation = { return [this.status] } - const statusesObject = this.$store.state.statuses.allStatusesObject - const conversation = this.idsToShow.reduce((acc, id) => { - acc.push(statusesObject[id]) - return acc - }, []) - + const conversation = clone(this.$store.state.statuses.conversationsObject[this.conversationId]) const statusIndex = findIndex(conversation, { id: this.statusId }) if (statusIndex !== -1) { conversation[statusIndex] = this.status @@ -131,10 +123,6 @@ const conversation = { .then(({ancestors, descendants}) => { this.$store.dispatch('addNewStatuses', { statuses: ancestors }) this.$store.dispatch('addNewStatuses', { statuses: descendants }) - set(this, 'converationStatusIds', [].concat( - ancestors.map(_ => _.id).filter(_ => _ !== this.statusId), - this.statusId, - descendants.map(_ => _.id).filter(_ => _ !== this.statusId))) }) .then(() => this.setHighlight(this.statusId)) } else { @@ -152,6 +140,7 @@ const conversation = { }, setHighlight (id) { this.highlight = id + this.$store.dispatch('fetchFavsAndRepeats', id) }, getHighlight () { return this.isExpanded ? this.highlight : null diff --git a/src/components/conversation/conversation.vue b/src/components/conversation/conversation.vue index c39a3ed9..0b4998c3 100644 --- a/src/components/conversation/conversation.vue +++ b/src/components/conversation/conversation.vue @@ -11,9 +11,10 @@ @goto="setHighlight" @toggleExpanded="toggleExpanded" :key="status.id" - :inlineExpanded="collapsable" + :inlineExpanded="collapsable && isExpanded" :statusoid="status" - :expandable='!expanded' + :expandable='!isExpanded' + :showPinned="showPinned" :focused="focused(status.id)" :inConversation="isExpanded" :highlight="getHighlight()" diff --git a/src/components/delete_button/delete_button.js b/src/components/delete_button/delete_button.js deleted file mode 100644 index f2920666..00000000 --- a/src/components/delete_button/delete_button.js +++ /dev/null @@ -1,17 +0,0 @@ -const DeleteButton = { - props: [ 'status' ], - methods: { - deleteStatus () { - const confirmed = window.confirm('Do you really want to delete this status?') - if (confirmed) { - this.$store.dispatch('deleteStatus', { id: this.status.id }) - } - } - }, - computed: { - currentUser () { return this.$store.state.users.currentUser }, - canDelete () { return this.currentUser && this.currentUser.rights.delete_others_notice || this.status.user.id === this.currentUser.id } - } -} - -export default DeleteButton diff --git a/src/components/delete_button/delete_button.vue b/src/components/delete_button/delete_button.vue deleted file mode 100644 index f4c91cfd..00000000 --- a/src/components/delete_button/delete_button.vue +++ /dev/null @@ -1,21 +0,0 @@ -<template> - <div v-if="canDelete"> - <a href="#" v-on:click.prevent="deleteStatus()"> - <i class='button-icon icon-cancel delete-status'></i> - </a> - </div> -</template> - -<script src="./delete_button.js" ></script> - -<style lang="scss"> -@import '../../_variables.scss'; - -.icon-cancel,.delete-status { - cursor: pointer; - &:hover { - color: $fallback--cRed; - color: var(--cRed, $fallback--cRed); - } -} -</style> diff --git a/src/components/dialog_modal/dialog_modal.js b/src/components/dialog_modal/dialog_modal.js new file mode 100644 index 00000000..f14e3fe9 --- /dev/null +++ b/src/components/dialog_modal/dialog_modal.js @@ -0,0 +1,14 @@ +const DialogModal = { + props: { + darkOverlay: { + default: true, + type: Boolean + }, + onCancel: { + default: () => {}, + type: Function + } + } +} + +export default DialogModal diff --git a/src/components/dialog_modal/dialog_modal.vue b/src/components/dialog_modal/dialog_modal.vue new file mode 100644 index 00000000..3da543de --- /dev/null +++ b/src/components/dialog_modal/dialog_modal.vue @@ -0,0 +1,94 @@ +<template> + <span v-bind:class="{ 'dark-overlay': darkOverlay }" @click.self.stop='onCancel()'> + <div class="dialog-modal panel panel-default" @click.stop=''> + <div class="panel-heading dialog-modal-heading"> + <div class="title"> + <slot name="header"></slot> + </div> + </div> + <div class="dialog-modal-content"> + <slot name="default"></slot> + </div> + <div class="dialog-modal-footer user-interactions panel-footer"> + <slot name="footer"></slot> + </div> + </div> + </span> +</template> + +<script src="./dialog_modal.js"></script> + +<style lang="scss"> +@import '../../_variables.scss'; + +// TODO: unify with other modals. +.dark-overlay { + &::before { + bottom: 0; + content: " "; + display: block; + cursor: default; + left: 0; + position: fixed; + right: 0; + top: 0; + background: rgba(27,31,35,.5); + z-index: 99; + } +} + +.dialog-modal.panel { + top: 0; + left: 50%; + max-height: 80vh; + max-width: 90vw; + margin: 15vh auto; + position: fixed; + transform: translateX(-50%); + z-index: 999; + cursor: default; + display: block; + background-color: $fallback--bg; + background-color: var(--bg, $fallback--bg); + + .dialog-modal-heading { + padding: .5em .5em; + margin-right: auto; + margin-bottom: 0; + white-space: nowrap; + color: var(--panelText); + background-color: $fallback--fg; + background-color: var(--panel, $fallback--fg); + + .title { + margin-bottom: 0; + text-align: center; + } + } + + .dialog-modal-content { + margin: 0; + padding: 1rem 1rem; + background-color: $fallback--lightBg; + background-color: var(--lightBg, $fallback--lightBg); + white-space: normal; + } + + .dialog-modal-footer { + margin: 0; + padding: .5em .5em; + background-color: $fallback--lightBg; + background-color: var(--lightBg, $fallback--lightBg); + border-top: 1px solid $fallback--bg; + border-top: 1px solid var(--bg, $fallback--bg); + display: flex; + justify-content: flex-end; + + button { + width: auto; + margin-left: .5rem; + } + } +} + +</style> diff --git a/src/components/exporter/exporter.js b/src/components/exporter/exporter.js new file mode 100644 index 00000000..8f507416 --- /dev/null +++ b/src/components/exporter/exporter.js @@ -0,0 +1,48 @@ +const Exporter = { + props: { + getContent: { + type: Function, + required: true + }, + filename: { + type: String, + default: 'export.csv' + }, + exportButtonLabel: { + type: String, + default () { + return this.$t('exporter.export') + } + }, + processingMessage: { + type: String, + default () { + return this.$t('exporter.processing') + } + } + }, + data () { + return { + processing: false + } + }, + methods: { + process () { + this.processing = true + this.getContent() + .then((content) => { + const fileToDownload = document.createElement('a') + fileToDownload.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(content)) + fileToDownload.setAttribute('download', this.filename) + fileToDownload.style.display = 'none' + document.body.appendChild(fileToDownload) + fileToDownload.click() + document.body.removeChild(fileToDownload) + // Add delay before hiding processing state since browser takes some time to handle file download + setTimeout(() => { this.processing = false }, 2000) + }) + } + } +} + +export default Exporter diff --git a/src/components/exporter/exporter.vue b/src/components/exporter/exporter.vue new file mode 100644 index 00000000..f22e579e --- /dev/null +++ b/src/components/exporter/exporter.vue @@ -0,0 +1,20 @@ +<template> + <div class="exporter"> + <div v-if="processing"> + <i class="icon-spin4 animate-spin exporter-processing"></i> + <span>{{processingMessage}}</span> + </div> + <button class="btn btn-default" @click="process" v-else>{{exportButtonLabel}}</button> + </div> +</template> + +<script src="./exporter.js"></script> + +<style lang="scss"> +.exporter { + &-processing { + font-size: 1.5em; + margin: 0.25em; + } +} +</style> diff --git a/src/components/extra_buttons/extra_buttons.js b/src/components/extra_buttons/extra_buttons.js new file mode 100644 index 00000000..528da301 --- /dev/null +++ b/src/components/extra_buttons/extra_buttons.js @@ -0,0 +1,64 @@ +import Popper from 'vue-popperjs/src/component/popper.js.vue' + +const ExtraButtons = { + props: [ 'status' ], + components: { + Popper + }, + data () { + return { + showDropDown: false, + showPopper: true + } + }, + methods: { + deleteStatus () { + this.refreshPopper() + const confirmed = window.confirm(this.$t('status.delete_confirm')) + if (confirmed) { + this.$store.dispatch('deleteStatus', { id: this.status.id }) + } + }, + toggleMenu () { + this.showDropDown = !this.showDropDown + }, + pinStatus () { + this.refreshPopper() + this.$store.dispatch('pinStatus', this.status.id) + .then(() => this.$emit('onSuccess')) + .catch(err => this.$emit('onError', err.error.error)) + }, + unpinStatus () { + this.refreshPopper() + this.$store.dispatch('unpinStatus', this.status.id) + .then(() => this.$emit('onSuccess')) + .catch(err => this.$emit('onError', err.error.error)) + }, + refreshPopper () { + this.showPopper = false + this.showDropDown = false + setTimeout(() => { + this.showPopper = true + }) + } + }, + computed: { + currentUser () { return this.$store.state.users.currentUser }, + canDelete () { + if (!this.currentUser) { return } + const superuser = this.currentUser.rights.moderator || this.currentUser.rights.admin + return superuser || this.status.user.id === this.currentUser.id + }, + ownStatus () { + return this.status.user.id === this.currentUser.id + }, + canPin () { + return this.ownStatus && (this.status.visibility === 'public' || this.status.visibility === 'unlisted') + }, + enabled () { + return this.canPin || this.canDelete + } + } +} + +export default ExtraButtons diff --git a/src/components/extra_buttons/extra_buttons.vue b/src/components/extra_buttons/extra_buttons.vue new file mode 100644 index 00000000..a761d313 --- /dev/null +++ b/src/components/extra_buttons/extra_buttons.vue @@ -0,0 +1,48 @@ +<template> + <Popper + trigger="click" + @hide='showDropDown = false' + append-to-body + v-if="enabled && showPopper" + :options="{ + placement: 'top', + modifiers: { + arrow: { enabled: true }, + offset: { offset: '0, 5px' }, + } + }" + > + <div class="popper-wrapper"> + <div class="dropdown-menu"> + <button class="dropdown-item dropdown-item-icon" @click.prevent="pinStatus" v-if="!status.pinned && canPin"> + <i class="icon-pin"></i><span>{{$t("status.pin")}}</span> + </button> + <button class="dropdown-item dropdown-item-icon" @click.prevent="unpinStatus" v-if="status.pinned && canPin"> + <i class="icon-pin"></i><span>{{$t("status.unpin")}}</span> + </button> + <button class="dropdown-item dropdown-item-icon" @click.prevent="deleteStatus" v-if="canDelete"> + <i class="icon-cancel"></i><span>{{$t("status.delete")}}</span> + </button> + </div> + </div> + <div class="button-icon" slot="reference" @click="toggleMenu"> + <i class='icon-ellipsis' :class="{'icon-clicked': showDropDown}"></i> + </div> + </Popper> +</template> + +<script src="./extra_buttons.js" ></script> + +<style lang="scss"> +@import '../../_variables.scss'; +@import '../popper/popper.scss'; + +.icon-ellipsis { + cursor: pointer; + + &:hover, &.icon-clicked { + color: $fallback--text; + color: var(--text, $fallback--text); + } +} +</style> diff --git a/src/components/features_panel/features_panel.js b/src/components/features_panel/features_panel.js index e0b7a118..5f0b7b25 100644 --- a/src/components/features_panel/features_panel.js +++ b/src/components/features_panel/features_panel.js @@ -6,7 +6,7 @@ const FeaturesPanel = { gopher: function () { return this.$store.state.instance.gopherAvailable }, whoToFollow: function () { return this.$store.state.instance.suggestionsEnabled }, mediaProxy: function () { return this.$store.state.instance.mediaProxyAvailable }, - scopeOptions: function () { return this.$store.state.instance.scopeOptionsEnabled }, + minimalScopesMode: function () { return this.$store.state.instance.minimalScopesMode }, textlimit: function () { return this.$store.state.instance.textlimit } } } diff --git a/src/components/features_panel/features_panel.vue b/src/components/features_panel/features_panel.vue index 445143e9..7a263e01 100644 --- a/src/components/features_panel/features_panel.vue +++ b/src/components/features_panel/features_panel.vue @@ -12,7 +12,7 @@ <li v-if="gopher">{{$t('features_panel.gopher')}}</li> <li v-if="whoToFollow">{{$t('features_panel.who_to_follow')}}</li> <li v-if="mediaProxy">{{$t('features_panel.media_proxy')}}</li> - <li v-if="scopeOptions">{{$t('features_panel.scope_options')}}</li> + <li>{{$t('features_panel.scope_options')}}</li> <li>{{$t('features_panel.text_limit')}} = {{textlimit}}</li> </ul> </div> diff --git a/src/components/follow_card/follow_card.js b/src/components/follow_card/follow_card.js index ac4e265a..dc4a0d41 100644 --- a/src/components/follow_card/follow_card.js +++ b/src/components/follow_card/follow_card.js @@ -10,8 +10,7 @@ const FollowCard = { data () { return { inProgress: false, - requestSent: false, - updated: false + requestSent: false } }, components: { @@ -19,10 +18,8 @@ const FollowCard = { RemoteFollow }, computed: { - isMe () { return this.$store.state.users.currentUser.id === this.user.id }, - following () { return this.updated ? this.updated.following : this.user.following }, - showFollow () { - return !this.following || this.updated && !this.updated.following + isMe () { + return this.$store.state.users.currentUser.id === this.user.id }, loggedIn () { return this.$store.state.users.currentUser @@ -31,17 +28,15 @@ const FollowCard = { methods: { followUser () { this.inProgress = true - requestFollow(this.user, this.$store).then(({ sent, updated }) => { + requestFollow(this.user, this.$store).then(({ sent }) => { this.inProgress = false this.requestSent = sent - this.updated = updated }) }, unfollowUser () { this.inProgress = true - requestUnfollow(this.user, this.$store).then(({ updated }) => { + requestUnfollow(this.user, this.$store).then(() => { this.inProgress = false - this.updated = updated }) } } diff --git a/src/components/follow_card/follow_card.vue b/src/components/follow_card/follow_card.vue index 9f314fd3..94e2836f 100644 --- a/src/components/follow_card/follow_card.vue +++ b/src/components/follow_card/follow_card.vue @@ -4,34 +4,38 @@ <span class="faint" v-if="!noFollowsYou && user.follows_you"> {{ isMe ? $t('user_card.its_you') : $t('user_card.follows_you') }} </span> - <div class="follow-card-follow-button" v-if="showFollow && !loggedIn"> - <RemoteFollow :user="user" /> - </div> - <button - v-if="showFollow && loggedIn" - class="btn btn-default follow-card-follow-button" - @click="followUser" - :disabled="inProgress" - :title="requestSent ? $t('user_card.follow_again') : ''" - > - <template v-if="inProgress"> - {{ $t('user_card.follow_progress') }} - </template> - <template v-else-if="requestSent"> - {{ $t('user_card.follow_sent') }} - </template> - <template v-else> - {{ $t('user_card.follow') }} - </template> - </button> - <button v-if="following" class="btn btn-default follow-card-follow-button pressed" @click="unfollowUser" :disabled="inProgress"> - <template v-if="inProgress"> - {{ $t('user_card.follow_progress') }} - </template> - <template v-else> - {{ $t('user_card.follow_unfollow') }} - </template> - </button> + <template v-if="!loggedIn"> + <div class="follow-card-follow-button" v-if="!user.following"> + <RemoteFollow :user="user" /> + </div> + </template> + <template v-else> + <button + v-if="!user.following" + class="btn btn-default follow-card-follow-button" + @click="followUser" + :disabled="inProgress" + :title="requestSent ? $t('user_card.follow_again') : ''" + > + <template v-if="inProgress"> + {{ $t('user_card.follow_progress') }} + </template> + <template v-else-if="requestSent"> + {{ $t('user_card.follow_sent') }} + </template> + <template v-else> + {{ $t('user_card.follow') }} + </template> + </button> + <button v-else class="btn btn-default follow-card-follow-button pressed" @click="unfollowUser" :disabled="inProgress"> + <template v-if="inProgress"> + {{ $t('user_card.follow_progress') }} + </template> + <template v-else> + {{ $t('user_card.follow_unfollow') }} + </template> + </button> + </template> </div> </basic-user-card> </template> diff --git a/src/components/follow_requests/follow_requests.vue b/src/components/follow_requests/follow_requests.vue index b83c2d68..36901fb4 100644 --- a/src/components/follow_requests/follow_requests.vue +++ b/src/components/follow_requests/follow_requests.vue @@ -4,7 +4,7 @@ {{$t('nav.friend_requests')}} </div> <div class="panel-body"> - <FollowRequestCard v-for="request in requests" :key="request.id" :user="request"/> + <FollowRequestCard v-for="request in requests" :key="request.id" :user="request" class="list-item"/> </div> </div> </template> diff --git a/src/components/image_cropper/image_cropper.js b/src/components/image_cropper/image_cropper.js index 5ba8f04e..01361e25 100644 --- a/src/components/image_cropper/image_cropper.js +++ b/src/components/image_cropper/image_cropper.js @@ -70,22 +70,10 @@ const ImageCropper = { this.dataUrl = undefined this.$emit('close') }, - submit () { + submit (cropping = true) { this.submitting = true this.avatarUploadError = null - this.submitHandler(this.cropper, this.file) - .then(() => this.destroy()) - .catch((err) => { - this.submitError = err - }) - .finally(() => { - this.submitting = false - }) - }, - submitWithoutCropping () { - this.submitting = true - this.avatarUploadError = null - this.submitHandler(false, this.dataUrl) + this.submitHandler(cropping && this.cropper, this.file) .then(() => this.destroy()) .catch((err) => { this.submitError = err diff --git a/src/components/image_cropper/image_cropper.vue b/src/components/image_cropper/image_cropper.vue index 129e6f46..d2b86e9e 100644 --- a/src/components/image_cropper/image_cropper.vue +++ b/src/components/image_cropper/image_cropper.vue @@ -5,9 +5,9 @@ <img ref="img" :src="dataUrl" alt="" @load.stop="createCropper" /> </div> <div class="image-cropper-buttons-wrapper"> - <button class="btn" type="button" :disabled="submitting" @click="submit" v-text="saveText"></button> + <button class="btn" type="button" :disabled="submitting" @click="submit()" v-text="saveText"></button> <button class="btn" type="button" :disabled="submitting" @click="destroy" v-text="cancelText"></button> - <button class="btn" type="button" :disabled="submitting" @click="submitWithoutCropping" v-text="saveWithoutCroppingText"></button> + <button class="btn" type="button" :disabled="submitting" @click="submit(false)" v-text="saveWithoutCroppingText"></button> <i class="icon-spin4 animate-spin" v-if="submitting"></i> </div> <div class="alert error" v-if="submitError"> diff --git a/src/components/importer/importer.js b/src/components/importer/importer.js new file mode 100644 index 00000000..c5f9e4d2 --- /dev/null +++ b/src/components/importer/importer.js @@ -0,0 +1,53 @@ +const Importer = { + props: { + submitHandler: { + type: Function, + required: true + }, + submitButtonLabel: { + type: String, + default () { + return this.$t('importer.submit') + } + }, + successMessage: { + type: String, + default () { + return this.$t('importer.success') + } + }, + errorMessage: { + type: String, + default () { + return this.$t('importer.error') + } + } + }, + data () { + return { + file: null, + error: false, + success: false, + submitting: false + } + }, + methods: { + change () { + this.file = this.$refs.input.files[0] + }, + submit () { + this.dismiss() + this.submitting = true + this.submitHandler(this.file) + .then(() => { this.success = true }) + .catch(() => { this.error = true }) + .finally(() => { this.submitting = false }) + }, + dismiss () { + this.success = false + this.error = false + } + } +} + +export default Importer diff --git a/src/components/importer/importer.vue b/src/components/importer/importer.vue new file mode 100644 index 00000000..0c5aa93d --- /dev/null +++ b/src/components/importer/importer.vue @@ -0,0 +1,28 @@ +<template> + <div class="importer"> + <form> + <input type="file" ref="input" v-on:change="change" /> + </form> + <i class="icon-spin4 animate-spin importer-uploading" v-if="submitting"></i> + <button class="btn btn-default" v-else @click="submit">{{submitButtonLabel}}</button> + <div v-if="success"> + <i class="icon-cross" @click="dismiss"></i> + <p>{{successMessage}}</p> + </div> + <div v-else-if="error"> + <i class="icon-cross" @click="dismiss"></i> + <p>{{errorMessage}}</p> + </div> + </div> +</template> + +<script src="./importer.js"></script> + +<style lang="scss"> +.importer { + &-uploading { + font-size: 1.5em; + margin: 0.25em; + } +} +</style> diff --git a/src/components/interactions/interactions.js b/src/components/interactions/interactions.js new file mode 100644 index 00000000..d4e3cc17 --- /dev/null +++ b/src/components/interactions/interactions.js @@ -0,0 +1,25 @@ +import Notifications from '../notifications/notifications.vue' + +const tabModeDict = { + mentions: ['mention'], + 'likes+repeats': ['repeat', 'like'], + follows: ['follow'] +} + +const Interactions = { + data () { + return { + filterMode: tabModeDict['mentions'] + } + }, + methods: { + onModeSwitch (index, dataset) { + this.filterMode = tabModeDict[dataset.filter] + } + }, + components: { + Notifications + } +} + +export default Interactions diff --git a/src/components/interactions/interactions.vue b/src/components/interactions/interactions.vue new file mode 100644 index 00000000..38b2670d --- /dev/null +++ b/src/components/interactions/interactions.vue @@ -0,0 +1,25 @@ +<template> + <div class="panel panel-default"> + <div class="panel-heading"> + <div class="title"> + {{ $t("nav.interactions") }} + </div> + </div> + <tab-switcher + ref="tabSwitcher" + :onSwitch="onModeSwitch" + > + <span data-tab-dummy data-filter="mentions" :label="$t('nav.mentions')"/> + <span data-tab-dummy data-filter="likes+repeats" :label="$t('interactions.favs_repeats')"/> + <span data-tab-dummy data-filter="follows" :label="$t('interactions.follows')"/> + </tab-switcher> + <Notifications + ref="notifications" + :noHeading="true" + :minimalMode="true" + :filterMode="filterMode" + /> + </div> +</template> + +<script src="./interactions.js"></script> diff --git a/src/components/interface_language_switcher/interface_language_switcher.vue b/src/components/interface_language_switcher/interface_language_switcher.vue index 3f58af2c..9f7877c6 100644 --- a/src/components/interface_language_switcher/interface_language_switcher.vue +++ b/src/components/interface_language_switcher/interface_language_switcher.vue @@ -26,7 +26,7 @@ }, languageNames () { - return _.map(this.languageCodes, ISO6391.getName) + return _.map(this.languageCodes, this.getLanguageName) }, language: { @@ -36,6 +36,17 @@ this.$i18n.locale = val } } + }, + + methods: { + getLanguageName (code) { + const specialLanguageNames = { + 'ja': 'Japanese (やさしいにほんご)', + 'ja_pedantic': 'Japanese (日本語)', + 'zh': 'Chinese (简体中文)' + } + return specialLanguageNames[code] || ISO6391.getName(code) + } } } </script> diff --git a/src/components/list/list.vue b/src/components/list/list.vue new file mode 100644 index 00000000..7136915b --- /dev/null +++ b/src/components/list/list.vue @@ -0,0 +1,42 @@ +<template> + <div class="list"> + <div v-for="item in items" class="list-item" :key="getKey(item)"> + <slot name="item" :item="item" /> + </div> + <div class="list-empty-content faint" v-if="items.length === 0 && !!$slots.empty"> + <slot name="empty" /> + </div> + </div> +</template> + +<script> +export default { + props: { + items: { + type: Array, + default: () => [] + }, + getKey: { + type: Function, + default: item => item.id + } + } +} +</script> + +<style lang="scss"> +@import '../../_variables.scss'; + +.list { + &-item:not(:last-child) { + border-bottom: 1px solid; + border-bottom-color: $fallback--border; + border-bottom-color: var(--border, $fallback--border); + } + + &-empty-content { + text-align: center; + padding: 10px; + } +} +</style> diff --git a/src/components/login_form/login_form.js b/src/components/login_form/login_form.js index fb6dc651..93214646 100644 --- a/src/components/login_form/login_form.js +++ b/src/components/login_form/login_form.js @@ -1,50 +1,80 @@ +import { mapState, mapGetters, mapActions, mapMutations } from 'vuex' import oauthApi from '../../services/new_api/oauth.js' + const LoginForm = { data: () => ({ user: {}, - authError: false + error: false }), computed: { - loginMethod () { return this.$store.state.instance.loginMethod }, - loggingIn () { return this.$store.state.users.loggingIn }, - registrationOpen () { return this.$store.state.instance.registrationOpen } + isPasswordAuth () { return this.requiredPassword }, + isTokenAuth () { return this.requiredToken }, + ...mapState({ + registrationOpen: state => state.instance.registrationOpen, + instance: state => state.instance, + loggingIn: state => state.users.loggingIn, + oauth: state => state.oauth + }), + ...mapGetters( + 'authFlow', ['requiredPassword', 'requiredToken', 'requiredMFA'] + ) }, methods: { - oAuthLogin () { - oauthApi.login({ - oauth: this.$store.state.oauth, - instance: this.$store.state.instance.server, + ...mapMutations('authFlow', ['requireMFA']), + ...mapActions({ login: 'authFlow/login' }), + submit () { + this.isTokenAuth ? this.submitToken() : this.submitPassword() + }, + submitToken () { + const { clientId } = this.oauth + const data = { + clientId, + instance: this.instance.server, commit: this.$store.commit - }) + } + + oauthApi.getOrCreateApp(data) + .then((app) => { oauthApi.login({ ...app, ...data }) }) }, - submit () { + submitPassword () { + const { clientId } = this.oauth const data = { - oauth: this.$store.state.oauth, - instance: this.$store.state.instance.server + clientId, + oauth: this.oauth, + instance: this.instance.server, + commit: this.$store.commit } - this.clearError() + this.error = false + oauthApi.getOrCreateApp(data).then((app) => { oauthApi.getTokenWithCredentials( { - app, + ...app, instance: data.instance, username: this.user.username, password: this.user.password } ).then((result) => { if (result.error) { - this.authError = result.error - this.user.password = '' + if (result.error === 'mfa_required') { + this.requireMFA({app: app, settings: result}) + } else { + this.error = result.error + this.focusOnPasswordInput() + } return } - this.$store.commit('setToken', result.access_token) - this.$store.dispatch('loginUser', result.access_token) - this.$router.push({name: 'friends'}) + this.login(result).then(() => { + this.$router.push({name: 'friends'}) + }) }) }) }, - clearError () { - this.authError = false + clearError () { this.error = false }, + focusOnPasswordInput () { + let passwordInput = this.$refs.passwordInput + passwordInput.focus() + passwordInput.setSelectionRange(0, passwordInput.value.length) } } } diff --git a/src/components/login_form/login_form.vue b/src/components/login_form/login_form.vue index 27a8e48a..a2c5cf8f 100644 --- a/src/components/login_form/login_form.vue +++ b/src/components/login_form/login_form.vue @@ -1,47 +1,53 @@ <template> - <div class="login panel panel-default"> - <!-- Default panel contents --> - <div class="panel-heading"> - {{$t('login.login')}} - </div> - <div class="panel-body"> - <form v-if="loginMethod == 'password'" v-on:submit.prevent='submit(user)' class='login-form'> +<div class="login panel panel-default"> + <!-- Default panel contents --> + + <div class="panel-heading">{{$t('login.login')}}</div> + + <div class="panel-body"> + <form class='login-form' @submit.prevent='submit'> + <template v-if="isPasswordAuth"> <div class='form-group'> <label for='username'>{{$t('login.username')}}</label> - <input :disabled="loggingIn" v-model='user.username' class='form-control' id='username' v-bind:placeholder="$t('login.placeholder')"> + <input :disabled="loggingIn" v-model='user.username' + class='form-control' id='username' + :placeholder="$t('login.placeholder')"> </div> <div class='form-group'> <label for='password'>{{$t('login.password')}}</label> - <input :disabled="loggingIn" v-model='user.password' class='form-control' id='password' type='password'> - </div> - <div class='form-group'> - <div class='login-bottom'> - <div><router-link :to="{name: 'registration'}" v-if='registrationOpen' class='register'>{{$t('login.register')}}</router-link></div> - <button :disabled="loggingIn" type='submit' class='btn btn-default'>{{$t('login.login')}}</button> - </div> + <input :disabled="loggingIn" v-model='user.password' + ref='passwordInput' class='form-control' id='password' type='password'> </div> - </form> + </template> - <form v-if="loginMethod == 'token'" v-on:submit.prevent='oAuthLogin' class="login-form"> - <div class="form-group"> - <p>{{$t('login.description')}}</p> - </div> - <div class='form-group'> - <div class='login-bottom'> - <div><router-link :to="{name: 'registration'}" v-if='registrationOpen' class='register'>{{$t('login.register')}}</router-link></div> - <button :disabled="loggingIn" type='submit' class='btn btn-default'>{{$t('login.login')}}</button> + <div class="form-group" v-if="isTokenAuth"> + <p>{{$t('login.description')}}</p> + </div> + + <div class='form-group'> + <div class='login-bottom'> + <div> + <router-link :to="{name: 'registration'}" + v-if='registrationOpen' + class='register'> + {{$t('login.register')}} + </router-link> </div> - </div> - </form> - - <div v-if="authError" class='form-group'> - <div class='alert error'> - {{authError}} - <i class="button-icon icon-cancel" @click="clearError"></i> + <button :disabled="loggingIn" type='submit' class='btn btn-default'> + {{$t('login.login')}} + </button> </div> </div> + </form> + </div> + + <div v-if="error" class='form-group'> + <div class='alert error'> + {{error}} + <i class="button-icon icon-cancel" @click="clearError"></i> </div> </div> +</div> </template> <script src="./login_form.js" ></script> @@ -50,6 +56,10 @@ @import '../../_variables.scss'; .login-form { + display: flex; + flex-direction: column; + padding: 0.6em; + .btn { min-height: 28px; width: 10em; @@ -66,9 +76,30 @@ align-items: center; justify-content: space-between; } -} -.login { + .form-group { + display: flex; + flex-direction: column; + padding: 0.3em 0.5em 0.6em; + line-height:24px; + } + + .form-bottom { + display: flex; + padding: 0.5em; + height: 32px; + + button { + width: 10em; + } + + p { + margin: 0.35em; + padding: 0.35em; + display: flex; + } + } + .error { text-align: center; diff --git a/src/components/media_modal/media_modal.vue b/src/components/media_modal/media_modal.vue index 7f666603..a4c12d74 100644 --- a/src/components/media_modal/media_modal.vue +++ b/src/components/media_modal/media_modal.vue @@ -33,6 +33,8 @@ @import '../../_variables.scss'; .media-modal-view { + z-index: 1001; + &:hover { .modal-view-button-arrow { opacity: 0.75; diff --git a/src/components/mentions/mentions.vue b/src/components/mentions/mentions.vue index bba06da6..6b4e96e0 100644 --- a/src/components/mentions/mentions.vue +++ b/src/components/mentions/mentions.vue @@ -1,5 +1,5 @@ <template> - <Timeline :title="$t('nav.mentions')" v-bind:timeline="timeline" v-bind:timeline-name="'mentions'"/> + <Timeline :title="$t('nav.interactions')" v-bind:timeline="timeline" v-bind:timeline-name="'mentions'"/> </template> <script src="./mentions.js"></script> diff --git a/src/components/mfa_form/recovery_form.js b/src/components/mfa_form/recovery_form.js new file mode 100644 index 00000000..fbe9b437 --- /dev/null +++ b/src/components/mfa_form/recovery_form.js @@ -0,0 +1,41 @@ +import mfaApi from '../../services/new_api/mfa.js' +import { mapState, mapGetters, mapActions, mapMutations } from 'vuex' + +export default { + data: () => ({ + code: null, + error: false + }), + computed: { + ...mapGetters({ + authApp: 'authFlow/app', + authSettings: 'authFlow/settings' + }), + ...mapState({ instance: 'instance' }) + }, + methods: { + ...mapMutations('authFlow', ['requireTOTP', 'abortMFA']), + ...mapActions({ login: 'authFlow/login' }), + clearError () { this.error = false }, + submit () { + const data = { + app: this.authApp, + instance: this.instance.server, + mfaToken: this.authSettings.mfa_token, + code: this.code + } + + mfaApi.verifyRecoveryCode(data).then((result) => { + if (result.error) { + this.error = result.error + this.code = null + return + } + + this.login(result).then(() => { + this.$router.push({name: 'friends'}) + }) + }) + } + } +} diff --git a/src/components/mfa_form/recovery_form.vue b/src/components/mfa_form/recovery_form.vue new file mode 100644 index 00000000..e0e2d65b --- /dev/null +++ b/src/components/mfa_form/recovery_form.vue @@ -0,0 +1,42 @@ +<template> +<div class="login panel panel-default"> + <!-- Default panel contents --> + + <div class="panel-heading">{{$t('login.heading.recovery')}}</div> + + <div class="panel-body"> + <form class='login-form' @submit.prevent='submit'> + <div class='form-group'> + <label for='code'>{{$t('login.recovery_code')}}</label> + <input v-model='code' class='form-control' id='code'> + </div> + + <div class='form-group'> + <div class='login-bottom'> + <div> + <a href="#" @click.prevent="requireTOTP"> + {{$t('login.enter_two_factor_code')}} + </a> + <br /> + <a href="#" @click.prevent="abortMFA"> + {{$t('general.cancel')}} + </a> + </div> + <button type='submit' class='btn btn-default'> + {{$t('general.verify')}} + </button> + </div> + </div> + + </form> + </div> + + <div v-if="error" class='form-group'> + <div class='alert error'> + {{error}} + <i class="button-icon icon-cancel" @click="clearError"></i> + </div> + </div> +</div> +</template> +<script src="./recovery_form.js" ></script> diff --git a/src/components/mfa_form/totp_form.js b/src/components/mfa_form/totp_form.js new file mode 100644 index 00000000..6c94fe52 --- /dev/null +++ b/src/components/mfa_form/totp_form.js @@ -0,0 +1,40 @@ +import mfaApi from '../../services/new_api/mfa.js' +import { mapState, mapGetters, mapActions, mapMutations } from 'vuex' +export default { + data: () => ({ + code: null, + error: false + }), + computed: { + ...mapGetters({ + authApp: 'authFlow/app', + authSettings: 'authFlow/settings' + }), + ...mapState({ instance: 'instance' }) + }, + methods: { + ...mapMutations('authFlow', ['requireRecovery', 'abortMFA']), + ...mapActions({ login: 'authFlow/login' }), + clearError () { this.error = false }, + submit () { + const data = { + app: this.authApp, + instance: this.instance.server, + mfaToken: this.authSettings.mfa_token, + code: this.code + } + + mfaApi.verifyOTPCode(data).then((result) => { + if (result.error) { + this.error = result.error + this.code = null + return + } + + this.login(result).then(() => { + this.$router.push({name: 'friends'}) + }) + }) + } + } +} diff --git a/src/components/mfa_form/totp_form.vue b/src/components/mfa_form/totp_form.vue new file mode 100644 index 00000000..c547785e --- /dev/null +++ b/src/components/mfa_form/totp_form.vue @@ -0,0 +1,45 @@ +<template> +<div class="login panel panel-default"> + <!-- Default panel contents --> + + <div class="panel-heading"> + {{$t('login.heading.totp')}} + </div> + + <div class="panel-body"> + <form class='login-form' @submit.prevent='submit'> + <div class='form-group'> + <label for='code'> + {{$t('login.authentication_code')}} + </label> + <input v-model='code' class='form-control' id='code'> + </div> + + <div class='form-group'> + <div class='login-bottom'> + <div> + <a href="#" @click.prevent="requireRecovery"> + {{$t('login.enter_recovery_code')}} + </a> + <br /> + <a href="#" @click.prevent="abortMFA"> + {{$t('general.cancel')}} + </a> + </div> + <button type='submit' class='btn btn-default'> + {{$t('general.verify')}} + </button> + </div> + </div> + </form> + </div> + + <div v-if="error" class='form-group'> + <div class='alert error'> + {{error}} + <i class="button-icon icon-cancel" @click="clearError"></i> + </div> + </div> +</div> +</template> +<script src="./totp_form.js"></script> diff --git a/src/components/mobile_nav/mobile_nav.js b/src/components/mobile_nav/mobile_nav.js new file mode 100644 index 00000000..9b341a3b --- /dev/null +++ b/src/components/mobile_nav/mobile_nav.js @@ -0,0 +1,82 @@ +import SideDrawer from '../side_drawer/side_drawer.vue' +import Notifications from '../notifications/notifications.vue' +import MobilePostStatusModal from '../mobile_post_status_modal/mobile_post_status_modal.vue' +import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils' +import GestureService from '../../services/gesture_service/gesture_service' + +const MobileNav = { + components: { + SideDrawer, + Notifications, + MobilePostStatusModal + }, + data: () => ({ + notificationsCloseGesture: undefined, + notificationsOpen: false + }), + created () { + this.notificationsCloseGesture = GestureService.swipeGesture( + GestureService.DIRECTION_RIGHT, + this.closeMobileNotifications, + 50 + ) + }, + computed: { + currentUser () { + return this.$store.state.users.currentUser + }, + unseenNotifications () { + return unseenNotificationsFromStore(this.$store) + }, + unseenNotificationsCount () { + return this.unseenNotifications.length + }, + sitename () { return this.$store.state.instance.name } + }, + methods: { + toggleMobileSidebar () { + this.$refs.sideDrawer.toggleDrawer() + }, + openMobileNotifications () { + this.notificationsOpen = true + }, + closeMobileNotifications () { + if (this.notificationsOpen) { + // make sure to mark notifs seen only when the notifs were open and not + // from close-calls. + this.notificationsOpen = false + this.markNotificationsAsSeen() + } + }, + notificationsTouchStart (e) { + GestureService.beginSwipe(e, this.notificationsCloseGesture) + }, + notificationsTouchMove (e) { + GestureService.updateSwipe(e, this.notificationsCloseGesture) + }, + scrollToTop () { + window.scrollTo(0, 0) + }, + logout () { + this.$router.replace('/main/public') + this.$store.dispatch('logout') + }, + markNotificationsAsSeen () { + this.$refs.notifications.markAsSeen() + }, + onScroll ({ target: { scrollTop, clientHeight, scrollHeight } }) { + if (this.$store.state.config.autoLoad && scrollTop + clientHeight >= scrollHeight) { + this.$refs.notifications.fetchOlderNotifications() + } + } + }, + watch: { + $route () { + // handles closing notificaitons when you press any router-link on the + // notifications. + this.closeMobileNotifications() + } + } +} + +export default MobileNav diff --git a/src/components/mobile_nav/mobile_nav.vue b/src/components/mobile_nav/mobile_nav.vue new file mode 100644 index 00000000..dcac440a --- /dev/null +++ b/src/components/mobile_nav/mobile_nav.vue @@ -0,0 +1,144 @@ +<template> + <div> + <nav class='nav-bar container' id="nav"> + <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"></i> + </a> + <router-link class="site-name" :to="{ name: 'root' }" active-class="home">{{sitename}}</router-link> + </div> + <div class='item right'> + <a class="mobile-nav-button" v-if="currentUser" href="#" @click.stop.prevent="openMobileNotifications()"> + <i class="button-icon icon-bell-alt"></i> + <div class="alert-dot" v-if="unseenNotificationsCount"></div> + </a> + </div> + </div> + </nav> + <div v-if="currentUser" + class="mobile-notifications-drawer" + :class="{ 'closed': !notificationsOpen }" + @touchstart.stop="notificationsTouchStart" + @touchmove.stop="notificationsTouchMove" + > + <div class="mobile-notifications-header"> + <span class="title">{{$t('notifications.notifications')}}</span> + <a class="mobile-nav-button" @click.stop.prevent="closeMobileNotifications()"> + <i class="button-icon icon-cancel"/> + </a> + </div> + <div class="mobile-notifications" @scroll="onScroll"> + <Notifications ref="notifications" :noHeading="true"/> + </div> + </div> + <SideDrawer ref="sideDrawer" :logout="logout"/> + <MobilePostStatusModal /> + </div> +</template> + +<script src="./mobile_nav.js"></script> + +<style lang="scss"> +@import '../../_variables.scss'; + +.mobile-inner-nav { + width: 100%; + display: flex; + align-items: center; +} + +.mobile-nav-button { + display: flex; + justify-content: center; + width: 50px; + position: relative; + cursor: pointer; +} + +.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%); + } +} + +.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; + } +} + +.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; + } + } +} + +</style> diff --git a/src/components/mobile_post_status_modal/mobile_post_status_modal.js b/src/components/mobile_post_status_modal/mobile_post_status_modal.js index 2f24dd08..91b730e7 100644 --- a/src/components/mobile_post_status_modal/mobile_post_status_modal.js +++ b/src/components/mobile_post_status_modal/mobile_post_status_modal.js @@ -1,5 +1,5 @@ import PostStatusForm from '../post_status_form/post_status_form.vue' -import { throttle } from 'lodash' +import { debounce } from 'lodash' const MobilePostStatusModal = { components: { @@ -16,11 +16,15 @@ const MobilePostStatusModal = { } }, created () { - window.addEventListener('scroll', this.handleScroll) + if (this.autohideFloatingPostButton) { + this.activateFloatingPostButtonAutohide() + } window.addEventListener('resize', this.handleOSK) }, destroyed () { - window.removeEventListener('scroll', this.handleScroll) + if (this.autohideFloatingPostButton) { + this.deactivateFloatingPostButtonAutohide() + } window.removeEventListener('resize', this.handleOSK) }, computed: { @@ -28,10 +32,30 @@ const MobilePostStatusModal = { return this.$store.state.users.currentUser }, isHidden () { - return this.hidden || this.inputActive + return this.autohideFloatingPostButton && (this.hidden || this.inputActive) + }, + autohideFloatingPostButton () { + return !!this.$store.state.config.autohideFloatingPostButton + } + }, + watch: { + autohideFloatingPostButton: function (isEnabled) { + if (isEnabled) { + this.activateFloatingPostButtonAutohide() + } else { + this.deactivateFloatingPostButtonAutohide() + } } }, methods: { + activateFloatingPostButtonAutohide () { + window.addEventListener('scroll', this.handleScrollStart) + window.addEventListener('scroll', this.handleScrollEnd) + }, + deactivateFloatingPostButtonAutohide () { + window.removeEventListener('scroll', this.handleScrollStart) + window.removeEventListener('scroll', this.handleScrollEnd) + }, openPostForm () { this.postFormOpen = true this.hidden = true @@ -65,26 +89,19 @@ const MobilePostStatusModal = { this.inputActive = false } }, - handleScroll: throttle(function () { - const scrollAmount = window.scrollY - this.oldScrollPos - const scrollingDown = scrollAmount > 0 - - if (scrollingDown !== this.scrollingDown) { - this.amountScrolled = 0 - this.scrollingDown = scrollingDown - if (!scrollingDown) { - this.hidden = false - } - } else if (scrollingDown) { - this.amountScrolled += scrollAmount - if (this.amountScrolled > 100 && !this.hidden) { - this.hidden = true - } + handleScrollStart: debounce(function () { + if (window.scrollY > this.oldScrollPos) { + this.hidden = true + } else { + this.hidden = false } + this.oldScrollPos = window.scrollY + }, 100, {leading: true, trailing: false}), + handleScrollEnd: debounce(function () { + this.hidden = false this.oldScrollPos = window.scrollY - this.scrollingDown = scrollingDown - }, 100) + }, 100, {leading: false, trailing: true}) } } diff --git a/src/components/mobile_post_status_modal/mobile_post_status_modal.vue b/src/components/mobile_post_status_modal/mobile_post_status_modal.vue index 0a451c28..c762705b 100644 --- a/src/components/mobile_post_status_modal/mobile_post_status_modal.vue +++ b/src/components/mobile_post_status_modal/mobile_post_status_modal.vue @@ -7,7 +7,7 @@ > <div class="post-form-modal-panel panel" @click.stop=""> <div class="panel-heading">{{$t('post_status.new_status')}}</div> - <PostStatusForm class="panel-body" @posted="closePostForm"/> + <PostStatusForm class="panel-body" @posted="closePostForm" /> </div> </div> <button diff --git a/src/components/moderation_tools/moderation_tools.js b/src/components/moderation_tools/moderation_tools.js new file mode 100644 index 00000000..3eedeaa1 --- /dev/null +++ b/src/components/moderation_tools/moderation_tools.js @@ -0,0 +1,106 @@ +import DialogModal from '../dialog_modal/dialog_modal.vue' +import Popper from 'vue-popperjs/src/component/popper.js.vue' + +const FORCE_NSFW = 'mrf_tag:media-force-nsfw' +const STRIP_MEDIA = 'mrf_tag:media-strip' +const FORCE_UNLISTED = 'mrf_tag:force-unlisted' +const DISABLE_REMOTE_SUBSCRIPTION = 'mrf_tag:disable-remote-subscription' +const DISABLE_ANY_SUBSCRIPTION = 'mrf_tag:disable-any-subscription' +const SANDBOX = 'mrf_tag:sandbox' +const QUARANTINE = 'mrf_tag:quarantine' + +const ModerationTools = { + props: [ + 'user' + ], + data () { + return { + showDropDown: false, + tags: { + FORCE_NSFW, + STRIP_MEDIA, + FORCE_UNLISTED, + DISABLE_REMOTE_SUBSCRIPTION, + DISABLE_ANY_SUBSCRIPTION, + SANDBOX, + QUARANTINE + }, + showDeleteUserDialog: false + } + }, + components: { + DialogModal, + Popper + }, + computed: { + tagsSet () { + return new Set(this.user.tags) + }, + hasTagPolicy () { + return this.$store.state.instance.tagPolicyAvailable + } + }, + methods: { + toggleMenu () { + this.showDropDown = !this.showDropDown + }, + hasTag (tagName) { + return this.tagsSet.has(tagName) + }, + toggleTag (tag) { + const store = this.$store + if (this.tagsSet.has(tag)) { + store.state.api.backendInteractor.untagUser(this.user, tag).then(response => { + if (!response.ok) { return } + store.commit('untagUser', {user: this.user, tag}) + }) + } else { + store.state.api.backendInteractor.tagUser(this.user, tag).then(response => { + if (!response.ok) { return } + store.commit('tagUser', {user: this.user, tag}) + }) + } + }, + toggleRight (right) { + const store = this.$store + if (this.user.rights[right]) { + store.state.api.backendInteractor.deleteRight(this.user, right).then(response => { + if (!response.ok) { return } + store.commit('updateRight', {user: this.user, right: right, value: false}) + }) + } else { + store.state.api.backendInteractor.addRight(this.user, right).then(response => { + if (!response.ok) { return } + store.commit('updateRight', {user: this.user, right: right, value: true}) + }) + } + }, + toggleActivationStatus () { + const store = this.$store + const status = !!this.user.deactivated + store.state.api.backendInteractor.setActivationStatus(this.user, status).then(response => { + if (!response.ok) { return } + store.commit('updateActivationStatus', {user: this.user, status: status}) + }) + }, + deleteUserDialog (show) { + this.showDeleteUserDialog = show + }, + deleteUser () { + const store = this.$store + const user = this.user + const {id, name} = user + store.state.api.backendInteractor.deleteUser(user) + .then(e => { + this.$store.dispatch('markStatusesAsDeleted', status => user.id === status.user.id) + const isProfile = this.$route.name === 'external-user-profile' || this.$route.name === 'user-profile' + const isTargetUser = this.$route.params.name === name || this.$route.params.id === id + if (isProfile && isTargetUser) { + window.history.back() + } + }) + } + } +} + +export default ModerationTools diff --git a/src/components/moderation_tools/moderation_tools.vue b/src/components/moderation_tools/moderation_tools.vue new file mode 100644 index 00000000..6a5e8cc0 --- /dev/null +++ b/src/components/moderation_tools/moderation_tools.vue @@ -0,0 +1,110 @@ +<template> +<div class='block' style='position: relative'> + <Popper + trigger="click" + @hide='showDropDown = false' + append-to-body + :options="{ + placement: 'bottom-end', + modifiers: { + arrow: { enabled: true }, + offset: { offset: '0, 5px' }, + } + }"> + <div class="popper-wrapper"> + <div class="dropdown-menu"> + <span v-if='user.is_local'> + <button class="dropdown-item" @click='toggleRight("admin")'> + {{ $t(!!user.rights.admin ? 'user_card.admin_menu.revoke_admin' : 'user_card.admin_menu.grant_admin') }} + </button> + <button class="dropdown-item" @click='toggleRight("moderator")'> + {{ $t(!!user.rights.moderator ? 'user_card.admin_menu.revoke_moderator' : 'user_card.admin_menu.grant_moderator') }} + </button> + <div role="separator" class="dropdown-divider"></div> + </span> + <button class="dropdown-item" @click='toggleActivationStatus()'> + {{ $t(!!user.deactivated ? 'user_card.admin_menu.activate_account' : 'user_card.admin_menu.deactivate_account') }} + </button> + <button class="dropdown-item" @click='deleteUserDialog(true)'> + {{ $t('user_card.admin_menu.delete_account') }} + </button> + <div role="separator" class="dropdown-divider" v-if='hasTagPolicy'></div> + <span v-if='hasTagPolicy'> + <button class="dropdown-item" @click='toggleTag(tags.FORCE_NSFW)'> + {{ $t('user_card.admin_menu.force_nsfw') }} + <span class="menu-checkbox" v-bind:class="{ 'menu-checkbox-checked': hasTag(tags.FORCE_NSFW) }"></span> + </button> + <button class="dropdown-item" @click='toggleTag(tags.STRIP_MEDIA)'> + {{ $t('user_card.admin_menu.strip_media') }} + <span class="menu-checkbox" v-bind:class="{ 'menu-checkbox-checked': hasTag(tags.STRIP_MEDIA) }"></span> + </button> + <button class="dropdown-item" @click='toggleTag(tags.FORCE_UNLISTED)'> + {{ $t('user_card.admin_menu.force_unlisted') }} + <span class="menu-checkbox" v-bind:class="{ 'menu-checkbox-checked': hasTag(tags.FORCE_UNLISTED) }"></span> + </button> + <button class="dropdown-item" @click='toggleTag(tags.SANDBOX)'> + {{ $t('user_card.admin_menu.sandbox') }} + <span class="menu-checkbox" v-bind:class="{ 'menu-checkbox-checked': hasTag(tags.SANDBOX) }"></span> + </button> + <button class="dropdown-item" v-if='user.is_local' @click='toggleTag(tags.DISABLE_REMOTE_SUBSCRIPTION)'> + {{ $t('user_card.admin_menu.disable_remote_subscription') }} + <span class="menu-checkbox" v-bind:class="{ 'menu-checkbox-checked': hasTag(tags.DISABLE_REMOTE_SUBSCRIPTION) }"></span> + </button> + <button class="dropdown-item" v-if='user.is_local' @click='toggleTag(tags.DISABLE_ANY_SUBSCRIPTION)'> + {{ $t('user_card.admin_menu.disable_any_subscription') }} + <span class="menu-checkbox" v-bind:class="{ 'menu-checkbox-checked': hasTag(tags.DISABLE_ANY_SUBSCRIPTION) }"></span> + </button> + <button class="dropdown-item" v-if='user.is_local' @click='toggleTag(tags.QUARANTINE)'> + {{ $t('user_card.admin_menu.quarantine') }} + <span class="menu-checkbox" v-bind:class="{ 'menu-checkbox-checked': hasTag(tags.QUARANTINE) }"></span> + </button> + </span> + </div> + </div> + <button slot="reference" v-bind:class="{ pressed: showDropDown }" @click='toggleMenu'> + {{ $t('user_card.admin_menu.moderation') }} + </button> + </Popper> + <portal to="modal"> + <DialogModal v-if="showDeleteUserDialog" :onCancel='deleteUserDialog.bind(this, false)'> + <template slot="header">{{ $t('user_card.admin_menu.delete_user') }}</template> + <p>{{ $t('user_card.admin_menu.delete_user_confirmation') }}</p> + <template slot="footer"> + <button class="btn btn-default" @click='deleteUserDialog(false)'> + {{ $t('general.cancel') }} + </button> + <button class="btn btn-default danger" @click='deleteUser()'> + {{ $t('user_card.admin_menu.delete_user') }} + </button> + </template> + </DialogModal> + </portal> +</div> +</template> + +<script src="./moderation_tools.js"></script> + +<style lang="scss"> +@import '../../_variables.scss'; +@import '../popper/popper.scss'; + +.menu-checkbox { + float: right; + min-width: 22px; + max-width: 22px; + min-height: 22px; + max-height: 22px; + line-height: 22px; + text-align: center; + border-radius: 0px; + background-color: $fallback--fg; + background-color: var(--input, $fallback--fg); + box-shadow: 0px 0px 2px black inset; + box-shadow: var(--inputShadow); + + &.menu-checkbox-checked::after { + content: '✔'; + } +} + +</style> diff --git a/src/components/nav_panel/nav_panel.vue b/src/components/nav_panel/nav_panel.vue index 7a7212fb..e6e0f074 100644 --- a/src/components/nav_panel/nav_panel.vue +++ b/src/components/nav_panel/nav_panel.vue @@ -8,8 +8,8 @@ </router-link> </li> <li v-if='currentUser'> - <router-link :to="{ name: 'mentions', params: { username: currentUser.screen_name } }"> - {{ $t("nav.mentions") }} + <router-link :to="{ name: 'interactions', params: { username: currentUser.screen_name } }"> + {{ $t("nav.interactions") }} </router-link> </li> <li v-if='currentUser'> diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js index fe5b7018..e59e7497 100644 --- a/src/components/notification/notification.js +++ b/src/components/notification/notification.js @@ -21,16 +21,28 @@ const Notification = { }, userProfileLink (user) { return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames) + }, + getUser (notification) { + return this.$store.state.users.usersObject[notification.from_profile.id] } }, computed: { userClass () { - return highlightClass(this.notification.action.user) + return highlightClass(this.notification.from_profile) }, userStyle () { const highlight = this.$store.state.config.highlight - const user = this.notification.action.user + const user = this.notification.from_profile return highlightStyle(highlight[user.screen_name]) + }, + userInStore () { + return this.$store.getters.findUser(this.notification.from_profile.id) + }, + user () { + if (this.userInStore) { + return this.userInStore + } + return this.notification.from_profile } } } diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue index 5e9cef97..3427b9c5 100644 --- a/src/components/notification/notification.vue +++ b/src/components/notification/notification.vue @@ -1,15 +1,20 @@ <template> - <status v-if="notification.type === 'mention'" :compact="true" :statusoid="notification.status"></status> - <div class="non-mention" :class="[userClass, { highlighted: userStyle }]" :style="[ userStyle ]"v-else> - <a class='avatar-container' :href="notification.action.user.statusnet_profile_url" @click.stop.prevent.capture="toggleUserExpanded"> - <UserAvatar :compact="true" :betterShadow="betterShadow" :src="notification.action.user.profile_image_url_original"/> + <status + v-if="notification.type === 'mention'" + :compact="true" + :statusoid="notification.status" + > + </status> + <div class="non-mention" :class="[userClass, { highlighted: userStyle }]" :style="[ userStyle ]" v-else> + <a class='avatar-container' :href="notification.from_profile.statusnet_profile_url" @click.stop.prevent.capture="toggleUserExpanded"> + <UserAvatar :compact="true" :betterShadow="betterShadow" :user="notification.from_profile"/> </a> <div class='notification-right'> - <UserCard :user="notification.action.user" :rounded="true" :bordered="true" v-if="userExpanded"/> + <UserCard :user="getUser(notification)" :rounded="true" :bordered="true" v-if="userExpanded" /> <span class="notification-details"> <div class="name-and-action"> - <span class="username" v-if="!!notification.action.user.name_html" :title="'@'+notification.action.user.screen_name" v-html="notification.action.user.name_html"></span> - <span class="username" v-else :title="'@'+notification.action.user.screen_name">{{ notification.action.user.name }}</span> + <span class="username" v-if="!!notification.from_profile.name_html" :title="'@'+notification.from_profile.screen_name" v-html="notification.from_profile.name_html"></span> + <span class="username" v-else :title="'@'+notification.from_profile.screen_name">{{ notification.from_profile.name }}</span> <span v-if="notification.type === 'like'"> <i class="fa icon-star lit"></i> <small>{{$t('notifications.favorited_you')}}</small> @@ -23,19 +28,24 @@ <small>{{$t('notifications.followed_you')}}</small> </span> </div> - <div class="timeago"> + <div class="timeago" v-if="notification.type === 'follow'"> + <span class="faint"> + <timeago :since="notification.created_at" :auto-update="240"></timeago> + </span> + </div> + <div class="timeago" v-else> <router-link v-if="notification.status" :to="{ name: 'conversation', params: { id: notification.status.id } }" class="faint-link"> - <timeago :since="notification.action.created_at" :auto-update="240"></timeago> + <timeago :since="notification.created_at" :auto-update="240"></timeago> </router-link> </div> </span> <div class="follow-text" v-if="notification.type === 'follow'"> - <router-link :to="userProfileLink(notification.action.user)"> - @{{notification.action.user.screen_name}} + <router-link :to="userProfileLink(notification.from_profile)"> + @{{notification.from_profile.screen_name}} </router-link> </div> <template v-else> - <status class="faint" :compact="true" :statusoid="notification.status" :noHeading="true"></status> + <status class="faint" :compact="true" :statusoid="notification.action" :noHeading="true"></status> </template> </div> </div> diff --git a/src/components/notifications/notifications.js b/src/components/notifications/notifications.js index 9fc5e38a..6c4054fd 100644 --- a/src/components/notifications/notifications.js +++ b/src/components/notifications/notifications.js @@ -7,12 +7,14 @@ import { } from '../../services/notification_utils/notification_utils.js' const Notifications = { - created () { - const store = this.$store - const credentials = store.state.users.currentUser.credentials - - const fetcherId = notificationsFetcher.startFetching({ store, credentials }) - this.$store.commit('setNotificationFetcher', { fetcherId }) + props: { + // Disables display of panel header + noHeading: Boolean, + // Disables panel styles, unread mark, potentially other notification-related actions + // meant for "Interactions" timeline + minimalMode: Boolean, + // Custom filter mode, an array of strings, possible values 'mention', 'repeat', 'like', 'follow', used to override global filter for use in "Interactions" timeline + filterMode: Array }, data () { return { @@ -20,6 +22,9 @@ const Notifications = { } }, computed: { + mainClass () { + return this.minimalMode ? '' : 'panel panel-default' + }, notifications () { return notificationsFromStore(this.$store) }, @@ -30,7 +35,7 @@ const Notifications = { return unseenNotificationsFromStore(this.$store) }, visibleNotifications () { - return visibleNotificationsFromStore(this.$store) + return visibleNotificationsFromStore(this.$store, this.filterMode) }, unseenCount () { return this.unseenNotifications.length @@ -53,9 +58,13 @@ const Notifications = { }, methods: { markAsSeen () { - this.$store.dispatch('markNotificationsAsSeen', this.visibleNotifications) + this.$store.dispatch('markNotificationsAsSeen') }, fetchOlderNotifications () { + if (this.loading) { + return + } + const store = this.$store const credentials = store.state.users.currentUser.credentials store.commit('setNotificationsLoading', { value: true }) diff --git a/src/components/notifications/notifications.scss b/src/components/notifications/notifications.scss index c0b458cc..622d12f4 100644 --- a/src/components/notifications/notifications.scss +++ b/src/components/notifications/notifications.scss @@ -1,8 +1,10 @@ @import '../../_variables.scss'; .notifications { - // a bit of a hack to allow scrolling below notifications - padding-bottom: 15em; + &:not(.minimal) { + // a bit of a hack to allow scrolling below notifications + padding-bottom: 15em; + } .loadmore-error { color: $fallback--text; diff --git a/src/components/notifications/notifications.vue b/src/components/notifications/notifications.vue index 6f162b62..c71499b2 100644 --- a/src/components/notifications/notifications.vue +++ b/src/components/notifications/notifications.vue @@ -1,7 +1,7 @@ <template> - <div class="notifications"> - <div class="panel panel-default"> - <div class="panel-heading"> + <div :class="{ minimal: minimalMode }" class="notifications"> + <div :class="mainClass"> + <div v-if="!noHeading" class="panel-heading"> <div class="title"> {{$t('notifications.notifications')}} <span class="badge badge-notification unseen-count" v-if="unseenCount">{{unseenCount}}</span> @@ -12,7 +12,7 @@ <button v-if="unseenCount" @click.prevent="markAsSeen" class="read-button">{{$t('notifications.read')}}</button> </div> <div class="panel-body"> - <div v-for="notification in visibleNotifications" :key="notification.action.id" class="notification" :class='{"unseen": !notification.seen}'> + <div v-for="notification in visibleNotifications" :key="notification.id" class="notification" :class='{"unseen": !minimalMode && !notification.seen}'> <div class="notification-overlay"></div> <notification :notification="notification"></notification> </div> @@ -22,7 +22,9 @@ {{$t('notifications.no_more_notifications')}} </div> <a v-else-if="!loading" href="#" v-on:click.prevent="fetchOlderNotifications()"> - <div class="new-status-notification text-center panel-footer">{{$t('notifications.load_older')}}</div> + <div class="new-status-notification text-center panel-footer"> + {{ minimalMode ? $t('interactions.load_older') : $t('notifications.load_older')}} + </div> </a> <div v-else class="new-status-notification text-center panel-footer"> <i class="icon-spin3 animate-spin"/> diff --git a/src/components/oauth_callback/oauth_callback.js b/src/components/oauth_callback/oauth_callback.js index e3d45ee1..2c6ca235 100644 --- a/src/components/oauth_callback/oauth_callback.js +++ b/src/components/oauth_callback/oauth_callback.js @@ -4,14 +4,16 @@ const oac = { props: ['code'], mounted () { if (this.code) { + const { clientId } = this.$store.state.oauth + oauth.getToken({ - app: this.$store.state.oauth, + clientId, instance: this.$store.state.instance.server, code: this.code }).then((result) => { this.$store.commit('setToken', result.access_token) this.$store.dispatch('loginUser', result.access_token) - this.$router.push({name: 'friends'}) + this.$router.push({ name: 'friends' }) }) } } diff --git a/src/components/popper/popper.scss b/src/components/popper/popper.scss new file mode 100644 index 00000000..cfc5c8e7 --- /dev/null +++ b/src/components/popper/popper.scss @@ -0,0 +1,127 @@ +@import '../../_variables.scss'; + +.popper-wrapper { + z-index: 8; +} + +.popper-wrapper .popper__arrow { + width: 0; + height: 0; + border-style: solid; + position: absolute; + margin: 5px; +} + +.popper-wrapper[x-placement^="top"] { + margin-bottom: 5px; +} + +.popper-wrapper[x-placement^="top"] .popper__arrow { + border-width: 5px 5px 0 5px; + border-color: $fallback--bg transparent transparent transparent; + border-color: var(--bg, $fallback--bg) transparent transparent transparent; + bottom: -5px; + left: calc(50% - 5px); + margin-top: 0; + margin-bottom: 0; +} + +.popper-wrapper[x-placement^="bottom"] { + margin-top: 5px; +} + +.popper-wrapper[x-placement^="bottom"] .popper__arrow { + border-width: 0 5px 5px 5px; + border-color: transparent transparent $fallback--bg transparent; + border-color: transparent transparent var(--bg, $fallback--bg) transparent; + top: -5px; + left: calc(50% - 5px); + margin-top: 0; + margin-bottom: 0; +} + +.popper-wrapper[x-placement^="right"] { + margin-left: 5px; +} + +.popper-wrapper[x-placement^="right"] .popper__arrow { + border-width: 5px 5px 5px 0; + border-color: transparent $fallback--bg transparent transparent; + border-color: transparent var(--bg, $fallback--bg) transparent transparent; + left: -5px; + top: calc(50% - 5px); + margin-left: 0; + margin-right: 0; +} + +.popper-wrapper[x-placement^="left"] { + margin-right: 5px; +} + +.popper-wrapper[x-placement^="left"] .popper__arrow { + border-width: 5px 0 5px 5px; + border-color: transparent transparent transparent $fallback--bg; + border-color: transparent transparent transparent var(--bg, $fallback--bg); + right: -5px; + top: calc(50% - 5px); + margin-left: 0; + margin-right: 0; +} + +.dropdown-menu { + display: block; + padding: .5rem 0; + font-size: 1rem; + text-align: left; + list-style: none; + max-width: 100vw; + z-index: 10; + box-shadow: 1px 1px 4px rgba(0,0,0,.6); + box-shadow: var(--panelShadow); + border: none; + border-radius: $fallback--btnRadius; + border-radius: var(--btnRadius, $fallback--btnRadius); + background-color: $fallback--bg; + background-color: var(--bg, $fallback--bg); + + .dropdown-divider { + height: 0; + margin: .5rem 0; + overflow: hidden; + border-top: 1px solid $fallback--border; + border-top: 1px solid var(--border, $fallback--border); + } + + .dropdown-item { + line-height: 21px; + margin-right: 5px; + overflow: auto; + display: block; + padding: .25rem 1.0rem .25rem 1.5rem; + clear: both; + font-weight: 400; + text-align: inherit; + white-space: normal; + border: none; + border-radius: 0px; + background-color: transparent; + box-shadow: none; + width: 100%; + height: 100%; + + &-icon { + padding-left: 0.5rem; + + i { + margin-right: 0.25rem; + } + } + + &:hover { + // TODO: improve the look on breeze themes + background-color: $fallback--fg; + background-color: var(--btn, $fallback--fg); + box-shadow: none; + } + } +} diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js index 229aefb7..cbd2024a 100644 --- a/src/components/post_status_form/post_status_form.js +++ b/src/components/post_status_form/post_status_form.js @@ -1,5 +1,6 @@ import statusPoster from '../../services/status_poster/status_poster.service.js' import MediaUpload from '../media_upload/media_upload.vue' +import ScopeSelector from '../scope_selector/scope_selector.vue' import EmojiInput from '../emoji-input/emoji-input.vue' import fileTypeService from '../../services/file_type/file_type.service.js' import Completion from '../../services/completion/completion.js' @@ -30,6 +31,7 @@ const PostStatusForm = { ], components: { MediaUpload, + ScopeSelector, EmojiInput }, mounted () { @@ -80,14 +82,6 @@ const PostStatusForm = { } }, computed: { - vis () { - return { - public: { selected: this.newStatus.visibility === 'public' }, - unlisted: { selected: this.newStatus.visibility === 'unlisted' }, - private: { selected: this.newStatus.visibility === 'private' }, - direct: { selected: this.newStatus.visibility === 'direct' } - } - }, candidates () { const firstchar = this.textAtCaret.charAt(0) if (firstchar === '@') { @@ -135,6 +129,15 @@ const PostStatusForm = { users () { return this.$store.state.users.users }, + userDefaultScope () { + return this.$store.state.users.currentUser.default_scope + }, + showAllScopes () { + const minimalScopesMode = typeof this.$store.state.config.minimalScopesMode === 'undefined' + ? this.$store.state.instance.minimalScopesMode + : this.$store.state.config.minimalScopesMode + return !minimalScopesMode + }, emoji () { return this.$store.state.instance.emoji || [] }, @@ -159,8 +162,8 @@ const PostStatusForm = { isOverLengthLimit () { return this.hasStatusLengthLimit && (this.charactersLeft < 0) }, - scopeOptionsEnabled () { - return this.$store.state.instance.scopeOptionsEnabled + minimalScopesMode () { + return this.$store.state.instance.minimalScopesMode }, alwaysShowSubject () { if (typeof this.$store.state.config.alwaysShowSubjectInput !== 'undefined') { @@ -168,7 +171,7 @@ const PostStatusForm = { } else if (typeof this.$store.state.instance.alwaysShowSubjectInput !== 'undefined') { return this.$store.state.instance.alwaysShowSubjectInput } else { - return this.$store.state.instance.scopeOptionsEnabled + return true } }, formattingOptionsEnabled () { @@ -176,6 +179,12 @@ const PostStatusForm = { }, postFormats () { return this.$store.state.instance.postFormats || [] + }, + safeDMEnabled () { + return this.$store.state.instance.safeDM + }, + hideScopeNotice () { + return this.$store.state.config.hideScopeNotice } }, methods: { @@ -332,6 +341,9 @@ const PostStatusForm = { }, changeVis (visibility) { this.newStatus.visibility = visibility + }, + dismissScopeNotice () { + this.$store.dispatch('setOption', { name: 'hideScopeNotice', value: true }) } } } diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue index 9f9f16ba..25c5284f 100644 --- a/src/components/post_status_form/post_status_form.vue +++ b/src/components/post_status_form/post_status_form.vue @@ -3,13 +3,34 @@ <form @submit.prevent="postStatus(newStatus)"> <div class="form-group" > <i18n - v-if="!this.$store.state.users.currentUser.locked && this.newStatus.visibility == 'private'" + v-if="!$store.state.users.currentUser.locked && newStatus.visibility == 'private'" path="post_status.account_not_locked_warning" tag="p" class="visibility-notice"> <router-link :to="{ name: 'user-settings' }">{{ $t('post_status.account_not_locked_warning_link') }}</router-link> </i18n> - <p v-if="this.newStatus.visibility == 'direct'" class="visibility-notice">{{ $t('post_status.direct_warning') }}</p> + <p v-if="!hideScopeNotice && newStatus.visibility === 'public'" class="visibility-notice notice-dismissible"> + <span>{{ $t('post_status.scope_notice.public') }}</span> + <a v-on:click.prevent="dismissScopeNotice()" class="button-icon dismiss"> + <i class='icon-cancel'></i> + </a> + </p> + <p v-else-if="!hideScopeNotice && newStatus.visibility === 'unlisted'" class="visibility-notice notice-dismissible"> + <span>{{ $t('post_status.scope_notice.unlisted') }}</span> + <a v-on:click.prevent="dismissScopeNotice()" class="button-icon dismiss"> + <i class='icon-cancel'></i> + </a> + </p> + <p v-else-if="!hideScopeNotice && newStatus.visibility === 'private' && $store.state.users.currentUser.locked" class="visibility-notice notice-dismissible"> + <span>{{ $t('post_status.scope_notice.private') }}</span> + <a v-on:click.prevent="dismissScopeNotice()" class="button-icon dismiss"> + <i class='icon-cancel'></i> + </a> + </p> + <p v-else-if="newStatus.visibility === 'direct'" class="visibility-notice"> + <span v-if="safeDMEnabled">{{ $t('post_status.direct_warning_to_first_only') }}</span> + <span v-else>{{ $t('post_status.direct_warning_to_all') }}</span> + </p> <EmojiInput v-if="newStatus.spoilerText || alwaysShowSubject" type="text" @@ -37,7 +58,7 @@ > </textarea> <div class="visibility-tray"> - <span class="text-format" v-if="formattingOptionsEnabled"> + <div class="text-format" v-if="formattingOptionsEnabled"> <label for="post-content-type" class="select"> <select id="post-content-type" v-model="newStatus.contentType" class="form-control"> <option v-for="postFormat in postFormats" :key="postFormat" :value="postFormat"> @@ -46,14 +67,14 @@ </select> <i class="icon-down-open"></i> </label> - </span> - - <div v-if="scopeOptionsEnabled"> - <i v-on:click="changeVis('direct')" class="icon-mail-alt" :class="vis.direct" :title="$t('post_status.scope.direct')"></i> - <i v-on:click="changeVis('private')" class="icon-lock" :class="vis.private" :title="$t('post_status.scope.private')"></i> - <i v-on:click="changeVis('unlisted')" class="icon-lock-open-alt" :class="vis.unlisted" :title="$t('post_status.scope.unlisted')"></i> - <i v-on:click="changeVis('public')" class="icon-globe" :class="vis.public" :title="$t('post_status.scope.public')"></i> </div> + + <scope-selector + :showAll="showAllScopes" + :userDefault="userDefaultScope" + :originalScope="copyMessageScope" + :initialScope="newStatus.visibility" + :onScopeChange="changeVis"/> </div> </div> <div class="autocomplete-panel" v-if="candidates"> @@ -131,10 +152,11 @@ display: flex; justify-content: space-between; flex-direction: row-reverse; + padding-top: 5px; } } -.post-status-form, .login { +.post-status-form { .form-bottom { display: flex; padding: 0.5em; @@ -229,7 +251,7 @@ .form-group { display: flex; flex-direction: column; - padding: 0.3em 0.5em 0.6em; + padding: 0.25em 0.5em 0.5em; line-height:24px; } diff --git a/src/components/progress_button/progress_button.vue b/src/components/progress_button/progress_button.vue new file mode 100644 index 00000000..737360bb --- /dev/null +++ b/src/components/progress_button/progress_button.vue @@ -0,0 +1,35 @@ +<template> + <button :disabled="progress || disabled" @click="onClick"> + <template v-if="progress"> + <slot name="progress" /> + </template> + <template v-else> + <slot /> + </template> + </button> +</template> + +<script> +export default { + props: { + disabled: { + type: Boolean + }, + click: { // click event handler. Must return a promise + type: Function, + default: () => Promise.resolve() + } + }, + data () { + return { + progress: false + } + }, + methods: { + onClick () { + this.progress = true + this.click().then(() => { this.progress = false }) + } + } +} +</script> diff --git a/src/components/public_and_external_timeline/public_and_external_timeline.js b/src/components/public_and_external_timeline/public_and_external_timeline.js index d45677e0..f614c13b 100644 --- a/src/components/public_and_external_timeline/public_and_external_timeline.js +++ b/src/components/public_and_external_timeline/public_and_external_timeline.js @@ -7,7 +7,7 @@ const PublicAndExternalTimeline = { timeline () { return this.$store.state.statuses.timelines.publicAndExternal } }, created () { - this.$store.dispatch('startFetching', { timeline: 'publicAndExternal' }) + this.$store.dispatch('startFetchingTimeline', { timeline: 'publicAndExternal' }) }, destroyed () { this.$store.dispatch('stopFetching', 'publicAndExternal') diff --git a/src/components/public_timeline/public_timeline.js b/src/components/public_timeline/public_timeline.js index 64c951ac..8976a99c 100644 --- a/src/components/public_timeline/public_timeline.js +++ b/src/components/public_timeline/public_timeline.js @@ -7,7 +7,7 @@ const PublicTimeline = { timeline () { return this.$store.state.statuses.timelines.public } }, created () { - this.$store.dispatch('startFetching', { timeline: 'public' }) + this.$store.dispatch('startFetchingTimeline', { timeline: 'public' }) }, destroyed () { this.$store.dispatch('stopFetching', 'public') diff --git a/src/components/scope_selector/scope_selector.js b/src/components/scope_selector/scope_selector.js new file mode 100644 index 00000000..8a42ee7b --- /dev/null +++ b/src/components/scope_selector/scope_selector.js @@ -0,0 +1,54 @@ +const ScopeSelector = { + props: [ + 'showAll', + 'userDefault', + 'originalScope', + 'initialScope', + 'onScopeChange' + ], + data () { + return { + currentScope: this.initialScope + } + }, + computed: { + showNothing () { + return !this.showPublic && !this.showUnlisted && !this.showPrivate && !this.showDirect + }, + showPublic () { + return this.originalScope !== 'direct' && this.shouldShow('public') + }, + showUnlisted () { + return this.originalScope !== 'direct' && this.shouldShow('unlisted') + }, + showPrivate () { + return this.originalScope !== 'direct' && this.shouldShow('private') + }, + showDirect () { + return this.shouldShow('direct') + }, + css () { + return { + public: {selected: this.currentScope === 'public'}, + unlisted: {selected: this.currentScope === 'unlisted'}, + private: {selected: this.currentScope === 'private'}, + direct: {selected: this.currentScope === 'direct'} + } + } + }, + methods: { + shouldShow (scope) { + return this.showAll || + this.currentScope === scope || + this.originalScope === scope || + this.userDefault === scope || + scope === 'direct' + }, + changeVis (scope) { + this.currentScope = scope + this.onScopeChange && this.onScopeChange(scope) + } + } +} + +export default ScopeSelector diff --git a/src/components/scope_selector/scope_selector.vue b/src/components/scope_selector/scope_selector.vue new file mode 100644 index 00000000..5ebb5d56 --- /dev/null +++ b/src/components/scope_selector/scope_selector.vue @@ -0,0 +1,46 @@ +<template> +<div v-if="!showNothing" class="scope-selector"> + <i class="icon-mail-alt" + :class="css.direct" + :title="$t('post_status.scope.direct')" + v-if="showDirect" + @click="changeVis('direct')"> + </i> + <i class="icon-lock" + :class="css.private" + :title="$t('post_status.scope.private')" + v-if="showPrivate" + v-on:click="changeVis('private')"> + </i> + <i class="icon-lock-open-alt" + :class="css.unlisted" + :title="$t('post_status.scope.unlisted')" + v-if="showUnlisted" + @click="changeVis('unlisted')"> + </i> + <i class="icon-globe" + :class="css.public" + :title="$t('post_status.scope.public')" + v-if="showPublic" + @click="changeVis('public')"> + </i> +</div> +</template> + +<script src="./scope_selector.js"></script> + +<style lang="scss"> +@import '../../_variables.scss'; + +.scope-selector { + i { + font-size: 1.2em; + cursor: pointer; + + &.selected { + color: $fallback--lightText; + color: var(--lightText, $fallback--lightText); + } + } +} +</style> diff --git a/src/components/selectable_list/selectable_list.js b/src/components/selectable_list/selectable_list.js new file mode 100644 index 00000000..10980d46 --- /dev/null +++ b/src/components/selectable_list/selectable_list.js @@ -0,0 +1,66 @@ +import List from '../list/list.vue' +import Checkbox from '../checkbox/checkbox.vue' + +const SelectableList = { + components: { + List, + Checkbox + }, + props: { + items: { + type: Array, + default: () => [] + }, + getKey: { + type: Function, + default: item => item.id + } + }, + data () { + return { + selected: [] + } + }, + computed: { + allKeys () { + return this.items.map(this.getKey) + }, + filteredSelected () { + return this.allKeys.filter(key => this.selected.indexOf(key) !== -1) + }, + allSelected () { + return this.filteredSelected.length === this.items.length + }, + noneSelected () { + return this.filteredSelected.length === 0 + }, + someSelected () { + return !this.allSelected && !this.noneSelected + } + }, + methods: { + isSelected (item) { + return this.filteredSelected.indexOf(this.getKey(item)) !== -1 + }, + toggle (checked, item) { + const key = this.getKey(item) + const oldChecked = this.isSelected(key) + if (checked !== oldChecked) { + if (checked) { + this.selected.push(key) + } else { + this.selected.splice(this.selected.indexOf(key), 1) + } + } + }, + toggleAll (value) { + if (value) { + this.selected = this.allKeys.slice(0) + } else { + this.selected = [] + } + } + } +} + +export default SelectableList diff --git a/src/components/selectable_list/selectable_list.vue b/src/components/selectable_list/selectable_list.vue new file mode 100644 index 00000000..3f16c921 --- /dev/null +++ b/src/components/selectable_list/selectable_list.vue @@ -0,0 +1,63 @@ +<template> + <div class="selectable-list"> + <div class="selectable-list-header" v-if="items.length > 0"> + <div class="selectable-list-checkbox-wrapper"> + <Checkbox :checked="allSelected" @change="toggleAll" :indeterminate="someSelected">{{ $t('selectable_list.select_all') }}</Checkbox> + </div> + <div class="selectable-list-header-actions"> + <slot name="header" :selected="filteredSelected" /> + </div> + </div> + <List :items="items" :getKey="getKey"> + <template slot="item" slot-scope="{item}"> + <div class="selectable-list-item-inner" :class="{ 'selectable-list-item-selected-inner': isSelected(item) }"> + <div class="selectable-list-checkbox-wrapper"> + <Checkbox :checked="isSelected(item)" @change="checked => toggle(checked, item)" /> + </div> + <slot name="item" :item="item" /> + </div> + </template> + <template slot="empty"><slot name="empty" /></template> + </List> + </div> +</template> + +<script src="./selectable_list.js"></script> + +<style lang="scss"> +@import '../../_variables.scss'; + +.selectable-list { + &-item-inner { + display: flex; + align-items: center; + + > * { + min-width: 0; + } + } + + &-item-selected-inner { + background-color: $fallback--lightBg; + background-color: var(--lightBg, $fallback--lightBg); + } + + &-header { + display: flex; + align-items: center; + padding: 0.6em 0; + border-bottom: 2px solid; + border-bottom-color: $fallback--border; + border-bottom-color: var(--border, $fallback--border); + + &-actions { + flex: 1; + } + } + + &-checkbox-wrapper { + padding: 0 10px; + flex: none; + } +} +</style> diff --git a/src/components/settings/settings.js b/src/components/settings/settings.js index 1d5f75ed..c4aa45b2 100644 --- a/src/components/settings/settings.js +++ b/src/components/settings/settings.js @@ -46,6 +46,7 @@ const settings = { streamingLocal: user.streaming, pauseOnUnfocusedLocal: user.pauseOnUnfocused, hoverPreviewLocal: user.hoverPreview, + autohideFloatingPostButtonLocal: user.autohideFloatingPostButton, hideMutedPostsLocal: typeof user.hideMutedPosts === 'undefined' ? instance.hideMutedPosts @@ -70,13 +71,18 @@ const settings = { alwaysShowSubjectInputLocal: typeof user.alwaysShowSubjectInput === 'undefined' ? instance.alwaysShowSubjectInput : user.alwaysShowSubjectInput, - alwaysShowSubjectInputDefault: instance.alwaysShowSubjectInput, + alwaysShowSubjectInputDefault: this.$t('settings.values.' + instance.alwaysShowSubjectInput), scopeCopyLocal: typeof user.scopeCopy === 'undefined' ? instance.scopeCopy : user.scopeCopy, scopeCopyDefault: this.$t('settings.values.' + instance.scopeCopy), + minimalScopesModeLocal: typeof user.minimalScopesMode === 'undefined' + ? instance.minimalScopesMode + : user.minimalScopesMode, + minimalScopesModeDefault: this.$t('settings.values.' + instance.minimalScopesMode), + stopGifs: user.stopGifs, webPushNotificationsLocal: user.webPushNotifications, loopVideoSilentOnlyLocal: user.loopVideosSilentOnly, @@ -178,6 +184,9 @@ const settings = { hoverPreviewLocal (value) { this.$store.dispatch('setOption', { name: 'hoverPreview', value }) }, + autohideFloatingPostButtonLocal (value) { + this.$store.dispatch('setOption', { name: 'autohideFloatingPostButton', value }) + }, muteWordsString (value) { value = filter(value.split('\n'), (word) => trim(word).length > 0) this.$store.dispatch('setOption', { name: 'muteWords', value }) @@ -200,6 +209,9 @@ const settings = { postContentTypeLocal (value) { this.$store.dispatch('setOption', { name: 'postContentType', value }) }, + minimalScopesModeLocal (value) { + this.$store.dispatch('setOption', { name: 'minimalScopesMode', value }) + }, stopGifs (value) { this.$store.dispatch('setOption', { name: 'stopGifs', value }) }, diff --git a/src/components/settings/settings.vue b/src/components/settings/settings.vue index 33dad549..4cf6fae2 100644 --- a/src/components/settings/settings.vue +++ b/src/components/settings/settings.vue @@ -42,9 +42,7 @@ </li> <li> <input type="checkbox" id="collapseMessageWithSubject" v-model="collapseMessageWithSubjectLocal"> - <label for="collapseMessageWithSubject"> - {{$t('settings.collapse_subject')}} {{$t('settings.instance_default', { value: collapseMessageWithSubjectDefault })}} - </label> + <label for="collapseMessageWithSubject">{{$t('settings.collapse_subject')}} {{$t('settings.instance_default', { value: collapseMessageWithSubjectDefault })}}</label> </li> <li> <input type="checkbox" id="streaming" v-model="streamingLocal"> @@ -118,6 +116,16 @@ </label> </div> </li> + <li> + <input type="checkbox" id="minimalScopesMode" v-model="minimalScopesModeLocal"> + <label for="minimalScopesMode"> + {{$t('settings.minimal_scopes_mode')}} {{$t('settings.instance_default', { value: minimalScopesModeDefault })}} + </label> + </li> + <li> + <input type="checkbox" id="autohideFloatingPostButton" v-model="autohideFloatingPostButtonLocal"> + <label for="autohideFloatingPostButton">{{$t('settings.autohide_floating_post_button')}}</label> + </li> </ul> </div> @@ -295,70 +303,3 @@ <script src="./settings.js"> </script> - -<style lang="scss"> -@import '../../_variables.scss'; - -.setting-item { - border-bottom: 2px solid var(--fg, $fallback--fg); - margin: 1em 1em 1.4em; - padding-bottom: 1.4em; - - > div { - margin-bottom: .5em; - &:last-child { - margin-bottom: 0; - } - } - - &:last-child { - border-bottom: none; - padding-bottom: 0; - margin-bottom: 1em; - } - - select { - min-width: 10em; - } - - - textarea { - width: 100%; - height: 100px; - } - - .unavailable, - .unavailable i { - color: var(--cRed, $fallback--cRed); - color: $fallback--cRed; - } - - .btn { - min-height: 28px; - min-width: 10em; - padding: 0 2em; - } - - .number-input { - max-width: 6em; - } -} -.select-multiple { - display: flex; - .option-list { - margin: 0; - padding-left: .5em; - } -} -.setting-list, -.option-list{ - list-style-type: none; - padding-left: 2em; - li { - margin-bottom: 0.5em; - } - .suboptions { - margin-top: 0.3em - } -} -</style> diff --git a/src/components/side_drawer/side_drawer.vue b/src/components/side_drawer/side_drawer.vue index e5046496..6428b1b0 100644 --- a/src/components/side_drawer/side_drawer.vue +++ b/src/components/side_drawer/side_drawer.vue @@ -22,13 +22,13 @@ </router-link> </li> <li v-if="currentUser" @click="toggleDrawer"> - <router-link :to="{ name: 'notifications', params: { username: currentUser.screen_name } }"> - {{ $t("notifications.notifications") }} {{ unseenNotificationsCount > 0 ? `(${unseenNotificationsCount})` : '' }} + <router-link :to="{ name: 'dms', params: { username: currentUser.screen_name } }"> + {{ $t("nav.dms") }} </router-link> </li> <li v-if="currentUser" @click="toggleDrawer"> - <router-link :to="{ name: 'dms', params: { username: currentUser.screen_name } }"> - {{ $t("nav.dms") }} + <router-link :to="{ name: 'interactions', params: { username: currentUser.screen_name } }"> + {{ $t("nav.interactions") }} </router-link> </li> </ul> diff --git a/src/components/status/status.js b/src/components/status/status.js index 550fe76f..ea4c2b9d 100644 --- a/src/components/status/status.js +++ b/src/components/status/status.js @@ -1,17 +1,18 @@ import Attachment from '../attachment/attachment.vue' import FavoriteButton from '../favorite_button/favorite_button.vue' import RetweetButton from '../retweet_button/retweet_button.vue' -import DeleteButton from '../delete_button/delete_button.vue' +import ExtraButtons from '../extra_buttons/extra_buttons.vue' import PostStatusForm from '../post_status_form/post_status_form.vue' import UserCard from '../user_card/user_card.vue' import UserAvatar from '../user_avatar/user_avatar.vue' import Gallery from '../gallery/gallery.vue' import LinkPreview from '../link-preview/link-preview.vue' +import AvatarList from '../avatar_list/avatar_list.vue' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' import fileType from 'src/services/file_type/file_type.service' import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js' import { mentionMatchesUrl, extractTagFromUrl } from 'src/services/matcher/matcher.service.js' -import { filter, find, unescape } from 'lodash' +import { filter, find, unescape, uniqBy } from 'lodash' const Status = { name: 'Status', @@ -25,18 +26,19 @@ const Status = { 'replies', 'isPreview', 'noHeading', - 'inlineExpanded' + 'inlineExpanded', + 'showPinned' ], data () { return { replying: false, - expanded: false, unmuted: false, userExpanded: false, preview: null, showPreview: false, showingTall: this.inConversation && this.focused, showingLongSubject: false, + error: null, expandingSubject: typeof this.$store.state.config.collapseMessageWithSubject === 'undefined' ? !this.$store.state.instance.collapseMessageWithSubject : !this.$store.state.config.collapseMessageWithSubject, @@ -97,6 +99,10 @@ const Status = { return this.statusoid } }, + statusFromGlobalRepository () { + // NOTE: Consider to replace status with statusFromGlobalRepository + return this.$store.state.statuses.allStatusesObject[this.status.id] + }, loggedIn () { return !!this.$store.state.users.currentUser }, @@ -156,7 +162,7 @@ const Status = { if (this.$store.state.config.replyVisibility === 'all') { return false } - if (this.inlineExpanded || this.expanded || this.inConversation || !this.isReply) { + if (this.inConversation || !this.isReply) { return false } if (this.status.user.id === this.$store.state.users.currentUser.id) { @@ -170,7 +176,7 @@ const Status = { if (this.status.user.id === this.status.attentions[i].id) { continue } - if (checkFollowing && this.status.attentions[i].following) { + if (checkFollowing && this.$store.getters.findUser(this.status.attentions[i].id).following) { return false } if (this.status.attentions[i].id === this.$store.state.users.currentUser.id) { @@ -251,18 +257,39 @@ const Status = { }, maxThumbnails () { return this.$store.state.config.maxThumbnails + }, + contentHtml () { + if (!this.status.summary_html) { + return this.status.statusnet_html + } + return this.status.summary_html + '<br />' + this.status.statusnet_html + }, + combinedFavsAndRepeatsUsers () { + // Use the status from the global status repository since favs and repeats are saved in it + const combinedUsers = [].concat( + this.statusFromGlobalRepository.favoritedBy, + this.statusFromGlobalRepository.rebloggedBy + ) + return uniqBy(combinedUsers, 'id') + }, + ownStatus () { + return this.status.user.id === this.$store.state.users.currentUser.id + }, + tags () { + return this.status.tags.filter(tagObj => tagObj.hasOwnProperty('name')).map(tagObj => tagObj.name).join(' ') } }, components: { Attachment, FavoriteButton, RetweetButton, - DeleteButton, + ExtraButtons, PostStatusForm, UserCard, UserAvatar, Gallery, - LinkPreview + LinkPreview, + AvatarList }, methods: { visibilityIcon (visibility) { @@ -277,6 +304,12 @@ const Status = { return 'icon-globe' } }, + showError (error) { + this.error = error + }, + clearError () { + this.error = undefined + }, linkClicked (event) { let { target } = event if (target.tagName === 'SPAN') { diff --git a/src/components/status/status.vue b/src/components/status/status.vue index 1f415534..e1dd81ac 100644 --- a/src/components/status/status.vue +++ b/src/components/status/status.vue @@ -1,5 +1,9 @@ <template> <div class="status-el" v-if="!hideStatus" :class="[{ 'status-el_focused': isFocused }, { 'status-conversation': inlineExpanded }]"> + <div v-if="error" class="alert error"> + {{error}} + <i class="button-icon icon-cancel" @click="clearError"></i> + </div> <template v-if="muted && !isPreview"> <div class="media status container muted"> <small> @@ -12,8 +16,12 @@ </div> </template> <template v-else> + <div v-if="showPinned && statusoid.pinned" class="status-pin"> + <i class="fa icon-pin faint"></i> + <span class="faint">{{$t('status.pinned')}}</span> + </div> <div v-if="retweet && !noHeading && !inConversation" :class="[repeaterClass, { highlighted: repeaterStyle }]" :style="[repeaterStyle]" class="media container retweet-info"> - <UserAvatar class="media-left" v-if="retweet" :betterShadow="betterShadow" :src="statusoid.user.profile_image_url_original"/> + <UserAvatar class="media-left" v-if="retweet" :betterShadow="betterShadow" :user="statusoid.user"/> <div class="media-body faint"> <span class="user-name"> <router-link v-if="retweeterHtml" :to="retweeterProfileLink" v-html="retweeterHtml"/> @@ -24,10 +32,10 @@ </div> </div> - <div :class="[userClass, { highlighted: userStyle, 'is-retweet': retweet && !inConversation }]" :style="[ userStyle ]" class="media status"> + <div :class="[userClass, { highlighted: userStyle, 'is-retweet': retweet && !inConversation }]" :style="[ userStyle ]" class="media status" :data-tags="tags"> <div v-if="!noHeading" class="media-left"> <router-link :to="userProfileLink" @click.stop.prevent.capture.native="toggleUserExpanded"> - <UserAvatar :compact="compact" :betterShadow="betterShadow" :src="status.user.profile_image_url_original"/> + <UserAvatar :compact="compact" :betterShadow="betterShadow" :user="status.user"/> </router-link> </div> <div class="status-body"> @@ -91,23 +99,28 @@ </div> <div v-if="showPreview" class="status-preview-container"> - <status class="status-preview" v-if="preview" :isPreview="true" :statusoid="preview" :compact=true></status> - <div class="status-preview status-preview-loading" v-else> + <status class="status-preview" + v-if="preview" + :isPreview="true" + :statusoid="preview" + :compact="true" + /> + <div v-else class="status-preview status-preview-loading"> <i class="icon-spin4 animate-spin"></i> </div> </div> <div class="status-content-wrapper" :class="{ 'tall-status': !showingLongSubject }" v-if="longSubject"> - <a class="tall-status-hider" :class="{ 'tall-status-hider_focused': isFocused }" v-if="!showingLongSubject" href="#" @click.prevent="showingLongSubject=true">Show more</a> - <div @click.prevent="linkClicked" class="status-content media-body" v-html="status.statusnet_html"></div> - <a v-if="showingLongSubject" href="#" class="status-unhider" @click.prevent="showingLongSubject=false">Show less</a> + <a class="tall-status-hider" :class="{ 'tall-status-hider_focused': isFocused }" v-if="!showingLongSubject" href="#" @click.prevent="showingLongSubject=true">{{$t("general.show_more")}}</a> + <div @click.prevent="linkClicked" class="status-content media-body" v-html="contentHtml"></div> + <a v-if="showingLongSubject" href="#" class="status-unhider" @click.prevent="showingLongSubject=false">{{$t("general.show_less")}}</a> </div> <div :class="{'tall-status': hideTallStatus}" class="status-content-wrapper" v-else> - <a class="tall-status-hider" :class="{ 'tall-status-hider_focused': isFocused }" v-if="hideTallStatus" href="#" @click.prevent="toggleShowMore">Show more</a> - <div @click.prevent="linkClicked" class="status-content media-body" v-html="status.statusnet_html" v-if="!hideSubjectStatus"></div> + <a class="tall-status-hider" :class="{ 'tall-status-hider_focused': isFocused }" v-if="hideTallStatus" href="#" @click.prevent="toggleShowMore">{{$t("general.show_more")}}</a> + <div @click.prevent="linkClicked" class="status-content media-body" v-html="contentHtml" v-if="!hideSubjectStatus"></div> <div @click.prevent="linkClicked" class="status-content media-body" v-html="status.summary_html" v-else></div> - <a v-if="hideSubjectStatus" href="#" class="cw-status-hider" @click.prevent="toggleShowMore">Show more</a> - <a v-if="showingMore" href="#" class="status-unhider" @click.prevent="toggleShowMore">Show less</a> + <a v-if="hideSubjectStatus" href="#" class="cw-status-hider" @click.prevent="toggleShowMore">{{$t("general.show_more")}}</a> + <a v-if="showingMore" href="#" class="status-unhider" @click.prevent="toggleShowMore">{{$t("general.show_less")}}</a> </div> <div v-if="status.attachments && (!hideSubjectStatus || showingLongSubject)" class="attachments media-body"> @@ -133,19 +146,37 @@ <link-preview :card="status.card" :size="attachmentSize" :nsfw="nsfwClickthrough" /> </div> + <transition name="fade"> + <div class="favs-repeated-users" v-if="isFocused && combinedFavsAndRepeatsUsers.length > 0"> + <div class="stats"> + <div class="stat-count" v-if="statusFromGlobalRepository.rebloggedBy && statusFromGlobalRepository.rebloggedBy.length > 0"> + <a class="stat-title">{{ $t('status.repeats') }}</a> + <div class="stat-number">{{ statusFromGlobalRepository.rebloggedBy.length }}</div> + </div> + <div class="stat-count" v-if="statusFromGlobalRepository.favoritedBy && statusFromGlobalRepository.favoritedBy.length > 0"> + <a class="stat-title">{{ $t('status.favorites') }}</a> + <div class="stat-number">{{ statusFromGlobalRepository.favoritedBy.length }}</div> + </div> + <div class="avatar-row"> + <AvatarList :users="combinedFavsAndRepeatsUsers"></AvatarList> + </div> + </div> + </div> + </transition> + <div v-if="!noHeading && !isPreview" class='status-actions media-body'> - <div v-if="loggedIn"> - <i class="button-icon icon-reply" v-on:click.prevent="toggleReplying" :title="$t('tool_tip.reply')" :class="{'icon-reply-active': replying}"></i> + <div> + <i class="button-icon icon-reply" v-on:click.prevent="toggleReplying" :title="$t('tool_tip.reply')" :class="{'button-icon-active': replying}" v-if="loggedIn"/> + <i class="button-icon button-icon-disabled icon-reply" :title="$t('tool_tip.reply')" v-else /> <span v-if="status.replies_count > 0">{{status.replies_count}}</span> </div> <retweet-button :visibility='status.visibility' :loggedIn='loggedIn' :status='status'></retweet-button> <favorite-button :loggedIn='loggedIn' :status='status'></favorite-button> - <delete-button :status='status'></delete-button> + <extra-buttons :status="status" @onError="showError" @onSuccess="clearError"></extra-buttons> </div> </div> </div> <div class="container" v-if="replying"> - <div class="reply-left"/> <post-status-form class="reply-body" :reply-to="status.id" :attentions="status.attentions" :repliedUser="status.user" :copy-message-scope="status.visibility" :subject="replySubject" v-on:posted="toggleReplying"/> </div> </template> @@ -175,6 +206,13 @@ $status-margin: 0.75em; max-width: 100%; } +.status-pin { + padding: $status-margin $status-margin 0; + display: flex; + align-items: center; + justify-content: flex-end; +} + .status-preview { position: absolute; max-width: 95%; @@ -218,7 +256,6 @@ $status-margin: 0.75em; } .status-el { - hyphens: auto; overflow-wrap: break-word; word-wrap: break-word; word-break: break-word; @@ -547,15 +584,13 @@ $status-margin: 0.75em; } } -.icon-reply:hover { - color: $fallback--cBlue; - color: var(--cBlue, $fallback--cBlue); - cursor: pointer; -} - -.icon-reply.icon-reply-active { - color: $fallback--cBlue; - color: var(--cBlue, $fallback--cBlue); +.button-icon.icon-reply { + &:not(.button-icon-disabled):hover, + &.button-icon-active { + color: $fallback--cBlue; + color: var(--cBlue, $fallback--cBlue); + cursor: pointer; + } } .status:hover .animated.avatar { @@ -595,16 +630,11 @@ a.unmute { margin-left: auto; } -.reply-left { - flex: 0; - min-width: 48px; -} - .reply-body { flex: 1; } -.timeline > { +.timeline :not(.panel-disabled) > { .status-el:last-child { border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius; border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius); @@ -612,6 +642,50 @@ a.unmute { } } +.favs-repeated-users { + margin-top: $status-margin; + + .stats { + width: 100%; + display: flex; + line-height: 1em; + + .stat-count { + margin-right: $status-margin; + + .stat-title { + color: var(--faint, $fallback--faint); + font-size: 12px; + text-transform: uppercase; + position: relative; + } + + .stat-number { + font-weight: bolder; + font-size: 16px; + line-height: 1em; + } + } + + .avatar-row { + flex: 1; + overflow: hidden; + position: relative; + display: flex; + align-items: center; + + &::before { + content: ''; + position: absolute; + height: 100%; + width: 1px; + left: 0; + background-color: var(--faint, $fallback--faint); + } + } + } +} + @media all and (max-width: 800px) { .status-el { .retweet-info { diff --git a/src/components/tab_switcher/tab_switcher.js b/src/components/tab_switcher/tab_switcher.js index 423df258..c949b458 100644 --- a/src/components/tab_switcher/tab_switcher.js +++ b/src/components/tab_switcher/tab_switcher.js @@ -4,15 +4,18 @@ import './tab_switcher.scss' export default Vue.component('tab-switcher', { name: 'TabSwitcher', - props: ['renderOnlyFocused'], + props: ['renderOnlyFocused', 'onSwitch'], data () { return { active: this.$slots.default.findIndex(_ => _.tag) } }, methods: { - activateTab (index) { + activateTab (index, dataset) { return () => { + if (typeof this.onSwitch === 'function') { + this.onSwitch.call(null, index, this.$slots.default[index].elm.dataset) + } this.active = index } } @@ -37,7 +40,11 @@ export default Vue.component('tab-switcher', { return ( <div class={ classesWrapper.join(' ')}> - <button disabled={slot.data.attrs.disabled} onClick={this.activateTab(index)} class={ classesTab.join(' ') }>{slot.data.attrs.label}</button> + <button + disabled={slot.data.attrs.disabled} + onClick={this.activateTab(index)} + class={classesTab.join(' ')}> + {slot.data.attrs.label}</button> </div> ) }) diff --git a/src/components/tag_timeline/tag_timeline.js b/src/components/tag_timeline/tag_timeline.js index 41b09706..458eb1c5 100644 --- a/src/components/tag_timeline/tag_timeline.js +++ b/src/components/tag_timeline/tag_timeline.js @@ -3,7 +3,7 @@ import Timeline from '../timeline/timeline.vue' const TagTimeline = { created () { this.$store.commit('clearTimeline', { timeline: 'tag' }) - this.$store.dispatch('startFetching', { timeline: 'tag', tag: this.tag }) + this.$store.dispatch('startFetchingTimeline', { timeline: 'tag', tag: this.tag }) }, components: { Timeline @@ -15,7 +15,7 @@ const TagTimeline = { watch: { tag () { this.$store.commit('clearTimeline', { timeline: 'tag' }) - this.$store.dispatch('startFetching', { timeline: 'tag', tag: this.tag }) + this.$store.dispatch('startFetchingTimeline', { timeline: 'tag', tag: this.tag }) } }, destroyed () { diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js index 1da7d5cc..19d9a9ac 100644 --- a/src/components/timeline/timeline.js +++ b/src/components/timeline/timeline.js @@ -52,7 +52,7 @@ const Timeline = { window.addEventListener('scroll', this.scrollLoad) - if (this.timelineName === 'friends' && !credentials) { return false } + if (store.state.api.fetchers[this.timelineName]) { return false } timelineFetcher.fetchAndUpdate({ store, diff --git a/src/components/timeline/timeline.vue b/src/components/timeline/timeline.vue index e0a34bd1..e6a8d458 100644 --- a/src/components/timeline/timeline.vue +++ b/src/components/timeline/timeline.vue @@ -16,7 +16,7 @@ </div> <div :class="classes.body"> <div class="timeline"> - <conversation + <conversation v-for="status in timeline.visibleStatuses" class="status-fadein" :key="status.id" diff --git a/src/components/user_avatar/user_avatar.js b/src/components/user_avatar/user_avatar.js index e6fed3b5..a42b9c71 100644 --- a/src/components/user_avatar/user_avatar.js +++ b/src/components/user_avatar/user_avatar.js @@ -2,7 +2,7 @@ import StillImage from '../still-image/still-image.vue' const UserAvatar = { props: [ - 'src', + 'user', 'betterShadow', 'compact' ], diff --git a/src/components/user_avatar/user_avatar.vue b/src/components/user_avatar/user_avatar.vue index 6bf7123d..e5466fdf 100644 --- a/src/components/user_avatar/user_avatar.vue +++ b/src/components/user_avatar/user_avatar.vue @@ -1,8 +1,10 @@ <template> <StillImage class="avatar" + :alt="user.screen_name" + :title="user.screen_name" + :src="user.profile_image_url_original" :class="{ 'avatar-compact': compact, 'better-shadow': betterShadow }" - :src="imgSrc" :imageLoadError="imageLoadError" /> </template> diff --git a/src/components/user_card/user_card.js b/src/components/user_card/user_card.js index 197c61d5..7c6ffa89 100644 --- a/src/components/user_card/user_card.js +++ b/src/components/user_card/user_card.js @@ -1,5 +1,6 @@ import UserAvatar from '../user_avatar/user_avatar.vue' import RemoteFollow from '../remote_follow/remote_follow.vue' +import ModerationTools from '../moderation_tools/moderation_tools.vue' import { hex2rgb } from '../../services/color_convert/color_convert.js' import { requestFollow, requestUnfollow } from '../../services/follow_manipulate/follow_manipulate' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' @@ -93,15 +94,17 @@ export default { } }, visibleRole () { - const validRole = (this.user.role === 'admin' || this.user.role === 'moderator') - const showRole = this.isOtherUser || this.user.show_role - - return validRole && showRole && this.user.role + const rights = this.user.rights + if (!rights) { return } + const validRole = rights.admin || rights.moderator + const roleTitle = rights.admin ? 'admin' : 'moderator' + return validRole && roleTitle } }, components: { UserAvatar, - RemoteFollow + RemoteFollow, + ModerationTools }, methods: { followUser () { @@ -148,6 +151,9 @@ export default { }, userProfileLink (user) { return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames) + }, + reportUser () { + this.$store.dispatch('openUserReportingModal', this.user.id) } } } diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue index 3259d1c5..b4495673 100644 --- a/src/components/user_card/user_card.vue +++ b/src/components/user_card/user_card.vue @@ -4,26 +4,26 @@ <div class='user-info'> <div class='container'> <router-link :to="userProfileLink(user)"> - <UserAvatar :betterShadow="betterShadow" :src="user.profile_image_url_original"/> + <UserAvatar :betterShadow="betterShadow" :user="user"/> </router-link> - <div class="name-and-screen-name"> + <div class="user-summary"> <div class="top-line"> <div :title="user.name" class='user-name' v-if="user.name_html" v-html="user.name_html"></div> <div :title="user.name" class='user-name' v-else>{{user.name}}</div> <router-link :to="{ name: 'user-settings' }" v-if="!isOtherUser"> - <i class="button-icon icon-pencil usersettings" :title="$t('tool_tip.user_settings')"></i> + <i class="button-icon icon-wrench usersettings" :title="$t('tool_tip.user_settings')"></i> </router-link> <a :href="user.statusnet_profile_url" target="_blank" v-if="isOtherUser && !user.is_local"> <i class="icon-link-ext usersettings"></i> </a> </div> - <router-link class='user-screen-name' :to="userProfileLink(user)"> - <span class="handle">@{{user.screen_name}} - <span class="alert staff" v-if="!hideBio && !!visibleRole">{{visibleRole}}</span> - </span><span v-if="user.locked"><i class="icon icon-lock"></i></span> + <div class="bottom-line"> + <router-link class="user-screen-name" :to="userProfileLink(user)">@{{user.screen_name}}</router-link> + <span class="alert staff" v-if="!hideBio && !!visibleRole">{{visibleRole}}</span> + <span v-if="user.locked"><i class="icon icon-lock"></i></span> <span v-if="!hideUserStatsLocal && !hideBio" class="dailyAvg">{{dailyAvg}} {{ $t('user_card.per_day') }}</span> - </router-link> + </div> </div> </div> <div class="user-meta"> @@ -99,6 +99,14 @@ </button> </span> </div> + <div class='block' v-if='isOtherUser && loggedIn'> + <span> + <button @click="reportUser"> + {{ $t('user_card.report') }} + </button> + </span> + </div> + <ModerationTools :user='user' v-if='loggedIn.role === "admin"'/> </div> </div> </div> @@ -160,7 +168,7 @@ max-width: 100%; max-height: 400px; - .emoji { + &.emoji { width: 32px; height: 32px; } @@ -224,7 +232,7 @@ opacity: .8; } - .name-and-screen-name { + .user-summary { display: block; margin-left: 0.6em; text-align: left; @@ -241,6 +249,7 @@ vertical-align: middle; object-fit: contain } + .top-line { display: flex; } @@ -261,15 +270,19 @@ } } - .user-screen-name { - color: $fallback--lightText; - color: var(--lightText, $fallback--lightText); - display: inline-block; + .bottom-line { + display: flex; font-weight: light; font-size: 15px; - padding-right: 0.1em; - width: 100%; - display: flex; + + .user-screen-name { + min-width: 1px; + flex: 0 1 auto; + text-overflow: ellipsis; + overflow: hidden; + color: $fallback--lightText; + color: var(--lightText, $fallback--lightText); + } .dailyAvg { min-width: 1px; @@ -280,15 +293,9 @@ color: var(--text, $fallback--text); } - .handle { - min-width: 1px; - flex: 0 1 auto; - text-overflow: ellipsis; - overflow: hidden; - } - // TODO use proper colors .staff { + flex: none; text-transform: capitalize; color: $fallback--text; color: var(--btnText, $fallback--text); diff --git a/src/components/user_panel/user_panel.js b/src/components/user_panel/user_panel.js index d4478290..c2f51eb6 100644 --- a/src/components/user_panel/user_panel.js +++ b/src/components/user_panel/user_panel.js @@ -1,13 +1,15 @@ -import LoginForm from '../login_form/login_form.vue' +import AuthForm from '../auth_form/auth_form.js' import PostStatusForm from '../post_status_form/post_status_form.vue' import UserCard from '../user_card/user_card.vue' +import { mapState } from 'vuex' const UserPanel = { computed: { - user () { return this.$store.state.users.currentUser } + signedIn () { return this.user }, + ...mapState({ user: state => state.users.currentUser }) }, components: { - LoginForm, + AuthForm, PostStatusForm, UserCard } diff --git a/src/components/user_panel/user_panel.vue b/src/components/user_panel/user_panel.vue index 8310f30e..37e28ca5 100644 --- a/src/components/user_panel/user_panel.vue +++ b/src/components/user_panel/user_panel.vue @@ -1,13 +1,20 @@ <template> <div class="user-panel"> - <div v-if='user' class="panel panel-default" style="overflow: visible;"> + + <div v-if="signedIn" key="user-panel" class="panel panel-default signed-in"> <UserCard :user="user" :hideBio="true" rounded="top"/> <div class="panel-footer"> <post-status-form v-if='user'></post-status-form> </div> </div> - <login-form v-if='!user'></login-form> + <auth-form v-else key="user-panel"/> </div> </template> <script src="./user_panel.js"></script> + +<style lang="scss"> +.user-panel .signed-in { + overflow: visible; +} +</style> diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js index 82df4510..eab330e7 100644 --- a/src/components/user_profile/user_profile.js +++ b/src/components/user_profile/user_profile.js @@ -1,47 +1,38 @@ -import { compose } from 'vue-compose' import get from 'lodash/get' import UserCard from '../user_card/user_card.vue' import FollowCard from '../follow_card/follow_card.vue' import Timeline from '../timeline/timeline.vue' +import Conversation from '../conversation/conversation.vue' +import ModerationTools from '../moderation_tools/moderation_tools.vue' +import List from '../list/list.vue' import withLoadMore from '../../hocs/with_load_more/with_load_more' -import withList from '../../hocs/with_list/with_list' -const FollowerList = compose( - withLoadMore({ - fetch: (props, $store) => $store.dispatch('addFollowers', props.userId), - select: (props, $store) => get($store.getters.findUser(props.userId), 'followers', []), - destory: (props, $store) => $store.dispatch('clearFollowers', props.userId), - childPropName: 'entries', - additionalPropNames: ['userId'] - }), - withList({ getEntryProps: user => ({ user }) }) -)(FollowCard) +const FollowerList = withLoadMore({ + fetch: (props, $store) => $store.dispatch('fetchFollowers', props.userId), + select: (props, $store) => get($store.getters.findUser(props.userId), 'followerIds', []).map(id => $store.getters.findUser(id)), + destroy: (props, $store) => $store.dispatch('clearFollowers', props.userId), + childPropName: 'items', + additionalPropNames: ['userId'] +})(List) -const FriendList = compose( - withLoadMore({ - fetch: (props, $store) => $store.dispatch('addFriends', props.userId), - select: (props, $store) => get($store.getters.findUser(props.userId), 'friends', []), - destory: (props, $store) => $store.dispatch('clearFriends', props.userId), - childPropName: 'entries', - additionalPropNames: ['userId'] - }), - withList({ getEntryProps: user => ({ user }) }) -)(FollowCard) +const FriendList = withLoadMore({ + fetch: (props, $store) => $store.dispatch('fetchFriends', props.userId), + select: (props, $store) => get($store.getters.findUser(props.userId), 'friendIds', []).map(id => $store.getters.findUser(id)), + destroy: (props, $store) => $store.dispatch('clearFriends', props.userId), + childPropName: 'items', + additionalPropNames: ['userId'] +})(List) const UserProfile = { data () { return { error: false, - fetchedUserId: null + userId: null } }, created () { - if (!this.user.id) { - this.fetchUserId() - .then(() => this.startUp()) - } else { - this.startUp() - } + const routeParams = this.$route.params + this.load(routeParams.name || routeParams.id) }, destroyed () { this.cleanUp() @@ -56,29 +47,12 @@ const UserProfile = { media () { return this.$store.state.statuses.timelines.media }, - userId () { - return this.$route.params.id || this.user.id || this.fetchedUserId - }, - userName () { - return this.$route.params.name || this.user.screen_name - }, isUs () { return this.userId && this.$store.state.users.currentUser.id && this.userId === this.$store.state.users.currentUser.id }, - userInStore () { - const routeParams = this.$route.params - // This needs fetchedUserId so that computed will be refreshed when user is fetched - return this.$store.getters.findUser(this.fetchedUserId || routeParams.name || routeParams.id) - }, user () { - if (this.timeline.statuses[0]) { - return this.timeline.statuses[0].user - } - if (this.userInStore) { - return this.userInStore - } - return {} + return this.$store.getters.findUser(this.userId) }, isExternal () { return this.$route.name === 'external-user-profile' @@ -91,40 +65,39 @@ const UserProfile = { } }, methods: { - startFetchFavorites () { - if (this.isUs) { - this.$store.dispatch('startFetching', { timeline: 'favorites', userId: this.userId }) - } - }, - fetchUserId () { - let fetchPromise - if (this.userId && !this.$route.params.name) { - fetchPromise = this.$store.dispatch('fetchUser', this.userId) + load (userNameOrId) { + // Check if user data is already loaded in store + const user = this.$store.getters.findUser(userNameOrId) + if (user) { + this.userId = user.id + this.fetchTimelines() } else { - fetchPromise = this.$store.dispatch('fetchUser', this.userName) + this.$store.dispatch('fetchUser', userNameOrId) .then(({ id }) => { - this.fetchedUserId = id + this.userId = id + this.fetchTimelines() + }) + .catch((reason) => { + const errorMessage = get(reason, 'error.error') + if (errorMessage === 'No user with such user_id') { // Known error + this.error = this.$t('user_profile.profile_does_not_exist') + } else if (errorMessage) { + this.error = errorMessage + } else { + this.error = this.$t('user_profile.profile_loading_error') + } }) } - return fetchPromise - .catch((reason) => { - const errorMessage = get(reason, 'error.error') - if (errorMessage === 'No user with such user_id') { // Known error - this.error = this.$t('user_profile.profile_does_not_exist') - } else if (errorMessage) { - this.error = errorMessage - } else { - this.error = this.$t('user_profile.profile_loading_error') - } - }) - .then(() => this.startUp()) }, - startUp () { - if (this.userId) { - this.$store.dispatch('startFetching', { timeline: 'user', userId: this.userId }) - this.$store.dispatch('startFetching', { timeline: 'media', userId: this.userId }) - this.startFetchFavorites() + fetchTimelines () { + const userId = this.userId + this.$store.dispatch('startFetchingTimeline', { timeline: 'user', userId }) + this.$store.dispatch('startFetchingTimeline', { timeline: 'media', userId }) + if (this.isUs) { + this.$store.dispatch('startFetchingTimeline', { timeline: 'favorites', userId }) } + // Fetch all pinned statuses immediately + this.$store.dispatch('fetchPinnedStatuses', userId) }, cleanUp () { this.$store.dispatch('stopFetching', 'user') @@ -136,18 +109,16 @@ const UserProfile = { } }, watch: { - // userId can be undefined if we don't know it yet - userId (newVal) { + '$route.params.id': function (newVal) { if (newVal) { this.cleanUp() - this.startUp() + this.load(newVal) } }, - userName () { - if (this.$route.params.name) { - this.fetchUserId() + '$route.params.name': function (newVal) { + if (newVal) { this.cleanUp() - this.startUp() + this.load(newVal) } }, $route () { @@ -158,7 +129,10 @@ const UserProfile = { UserCard, Timeline, FollowerList, - FriendList + FriendList, + ModerationTools, + FollowCard, + Conversation } } diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue index d449eb85..48b774ea 100644 --- a/src/components/user_profile/user_profile.vue +++ b/src/components/user_profile/user_profile.vue @@ -1,23 +1,43 @@ <template> <div> - <div v-if="user.id" class="user-profile panel panel-default"> + <div v-if="user" class="user-profile panel panel-default"> <UserCard :user="user" :switcher="true" :selected="timeline.viewing" rounded="top"/> <tab-switcher :renderOnlyFocused="true" ref="tabSwitcher"> - <Timeline - :label="$t('user_card.statuses')" - :disabled="!user.statuses_count" - :count="user.statuses_count" - :embedded="true" - :title="$t('user_profile.timeline_title')" - :timeline="timeline" - :timeline-name="'user'" - :user-id="userId" - /> + <div :label="$t('user_card.statuses')" :disabled="!user.statuses_count"> + <div class="timeline"> + <template v-for="statusId in user.pinnedStatuseIds"> + <Conversation + v-if="timeline.statusesObject[statusId]" + class="status-fadein" + :key="statusId" + :statusoid="timeline.statusesObject[statusId]" + :collapsable="true" + :showPinned="true" + /> + </template> + </div> + <Timeline + :count="user.statuses_count" + :embedded="true" + :title="$t('user_profile.timeline_title')" + :timeline="timeline" + :timeline-name="'user'" + :user-id="userId" + /> + </div> <div :label="$t('user_card.followees')" v-if="followsTabVisible" :disabled="!user.friends_count"> - <FriendList :userId="userId" /> + <FriendList :userId="userId"> + <template slot="item" slot-scope="{item}"> + <FollowCard :user="item" /> + </template> + </FriendList> </div> <div :label="$t('user_card.followers')" v-if="followersTabVisible" :disabled="!user.followers_count"> - <FollowerList :userId="userId" :entryProps="{noFollowsYou: isUs}" /> + <FollowerList :userId="userId"> + <template slot="item" slot-scope="{item}"> + <FollowCard :user="item" :noFollowsYou="isUs" /> + </template> + </FollowerList> </div> <Timeline :label="$t('user_card.media')" diff --git a/src/components/user_reporting_modal/user_reporting_modal.js b/src/components/user_reporting_modal/user_reporting_modal.js new file mode 100644 index 00000000..7c6ea409 --- /dev/null +++ b/src/components/user_reporting_modal/user_reporting_modal.js @@ -0,0 +1,106 @@ + +import Status from '../status/status.vue' +import List from '../list/list.vue' +import Checkbox from '../checkbox/checkbox.vue' + +const UserReportingModal = { + components: { + Status, + List, + Checkbox + }, + data () { + return { + comment: '', + forward: false, + statusIdsToReport: [], + processing: false, + error: false + } + }, + computed: { + isLoggedIn () { + return !!this.$store.state.users.currentUser + }, + isOpen () { + return this.isLoggedIn && this.$store.state.reports.modalActivated + }, + userId () { + return this.$store.state.reports.userId + }, + user () { + return this.$store.getters.findUser(this.userId) + }, + remoteInstance () { + return !this.user.is_local && this.user.screen_name.substr(this.user.screen_name.indexOf('@') + 1) + }, + statuses () { + return this.$store.state.reports.statuses + } + }, + watch: { + userId: 'resetState' + }, + methods: { + resetState () { + // Reset state + this.comment = '' + this.forward = false + this.statusIdsToReport = [] + this.processing = false + this.error = false + }, + closeModal () { + this.$store.dispatch('closeUserReportingModal') + }, + reportUser () { + this.processing = true + this.error = false + const params = { + userId: this.userId, + comment: this.comment, + forward: this.forward, + statusIds: this.statusIdsToReport + } + this.$store.state.api.backendInteractor.reportUser(params) + .then(() => { + this.processing = false + this.resetState() + this.closeModal() + }) + .catch(() => { + this.processing = false + this.error = true + }) + }, + clearError () { + this.error = false + }, + isChecked (statusId) { + return this.statusIdsToReport.indexOf(statusId) !== -1 + }, + toggleStatus (checked, statusId) { + if (checked === this.isChecked(statusId)) { + return + } + + if (checked) { + this.statusIdsToReport.push(statusId) + } else { + this.statusIdsToReport.splice(this.statusIdsToReport.indexOf(statusId), 1) + } + }, + resize (e) { + const target = e.target || e + if (!(target instanceof window.Element)) { return } + // Auto is needed to make textbox shrink when removing lines + target.style.height = 'auto' + target.style.height = `${target.scrollHeight}px` + if (target.value === '') { + target.style.height = null + } + } + } +} + +export default UserReportingModal diff --git a/src/components/user_reporting_modal/user_reporting_modal.vue b/src/components/user_reporting_modal/user_reporting_modal.vue new file mode 100644 index 00000000..432dd14d --- /dev/null +++ b/src/components/user_reporting_modal/user_reporting_modal.vue @@ -0,0 +1,157 @@ +<template> +<div class="modal-view" @click="closeModal" v-if="isOpen"> + <div class="user-reporting-panel panel" @click.stop=""> + <div class="panel-heading"> + <div class="title">{{$t('user_reporting.title', [user.screen_name])}}</div> + </div> + <div class="panel-body"> + <div class="user-reporting-panel-left"> + <div> + <p>{{$t('user_reporting.add_comment_description')}}</p> + <textarea + v-model="comment" + class="form-control" + :placeholder="$t('user_reporting.additional_comments')" + rows="1" + @input="resize" + /> + </div> + <div v-if="!user.is_local"> + <p>{{$t('user_reporting.forward_description')}}</p> + <Checkbox v-model="forward">{{$t('user_reporting.forward_to', [remoteInstance])}}</Checkbox> + </div> + <div> + <button class="btn btn-default" @click="reportUser" :disabled="processing">{{$t('user_reporting.submit')}}</button> + <div class="alert error" v-if="error"> + {{$t('user_reporting.generic_error')}} + </div> + </div> + </div> + <div class="user-reporting-panel-right"> + <List :items="statuses"> + <template slot="item" slot-scope="{item}"> + <div class="status-fadein user-reporting-panel-sitem"> + <Status :inConversation="false" :focused="false" :statusoid="item" /> + <Checkbox :checked="isChecked(item.id)" @change="checked => toggleStatus(checked, item.id)" /> + </div> + </template> + </List> + </div> + </div> + </div> +</div> +</template> + +<script src="./user_reporting_modal.js"></script> + +<style lang="scss"> +@import '../../_variables.scss'; + +.user-reporting-panel { + width: 90vw; + max-width: 700px; + min-height: 20vh; + max-height: 80vh; + + .panel-heading { + .title { + text-align: center; + // TODO: Consider making these as default of panel + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + .panel-body { + display: flex; + flex-direction: column-reverse; + border-top: 1px solid; + border-color: $fallback--border; + border-color: var(--border, $fallback--border); + overflow: hidden; + } + + &-left { + padding: 1.1em 0.7em 0.7em; + line-height: 1.4em; + box-sizing: border-box; + + > div { + margin-bottom: 1em; + + &:last-child { + margin-bottom: 0; + } + } + + p { + margin-top: 0; + } + + textarea.form-control { + line-height: 16px; + resize: none; + overflow: hidden; + transition: min-height 200ms 100ms; + min-height: 44px; + width: 100%; + } + + .btn { + min-width: 10em; + padding: 0 2em; + } + + .alert { + margin: 1em 0 0 0; + line-height: 1.3em; + } + } + + &-right { + display: flex; + flex-direction: column; + overflow-y: auto; + } + + &-sitem { + display: flex; + justify-content: space-between; + + > .status-el { + flex: 1; + } + + > .checkbox { + margin: 0.75em; + } + } + + @media all and (min-width: 801px) { + .panel-body { + flex-direction: row; + } + + &-left { + width: 50%; + max-width: 320px; + border-right: 1px solid; + border-color: $fallback--border; + border-color: var(--border, $fallback--border); + padding: 1.1em; + + > div { + margin-bottom: 2em; + } + } + + &-right { + width: 50%; + flex: 1 1 auto; + margin-bottom: 12px; + } + } +} +</style> diff --git a/src/components/user_search/user_search.js b/src/components/user_search/user_search.js index 55040826..62dafdf1 100644 --- a/src/components/user_search/user_search.js +++ b/src/components/user_search/user_search.js @@ -1,5 +1,6 @@ import FollowCard from '../follow_card/follow_card.vue' -import userSearchApi from '../../services/new_api/user_search.js' +import map from 'lodash/map' + const userSearch = { components: { FollowCard @@ -10,10 +11,15 @@ const userSearch = { data () { return { username: '', - users: [], + userIds: [], loading: false } }, + computed: { + users () { + return this.userIds.map(userId => this.$store.getters.findUser(userId)) + } + }, mounted () { this.search(this.query) }, @@ -33,10 +39,10 @@ const userSearch = { return } this.loading = true - userSearchApi.search({query, store: this.$store}) + this.$store.dispatch('searchUsers', query) .then((res) => { this.loading = false - this.users = res + this.userIds = map(res, 'id') }) } } diff --git a/src/components/user_search/user_search.vue b/src/components/user_search/user_search.vue index 1269eea6..890b3c13 100644 --- a/src/components/user_search/user_search.vue +++ b/src/components/user_search/user_search.vue @@ -13,7 +13,7 @@ <i class="icon-spin3 animate-spin"/> </div> <div v-else class="panel-body"> - <FollowCard v-for="user in users" :key="user.id" :user="user"/> + <FollowCard v-for="user in users" :key="user.id" :user="user" class="list-item"/> </div> </div> </template> diff --git a/src/components/user_settings/confirm.js b/src/components/user_settings/confirm.js new file mode 100644 index 00000000..0f4ddfc9 --- /dev/null +++ b/src/components/user_settings/confirm.js @@ -0,0 +1,9 @@ +const Confirm = { + props: ['disabled'], + data: () => ({}), + methods: { + confirm () { this.$emit('confirm') }, + cancel () { this.$emit('cancel') } + } +} +export default Confirm diff --git a/src/components/user_settings/confirm.vue b/src/components/user_settings/confirm.vue new file mode 100644 index 00000000..46a42e38 --- /dev/null +++ b/src/components/user_settings/confirm.vue @@ -0,0 +1,14 @@ +<template> +<div> + <slot></slot> + <button class="btn btn-default" @click="confirm" :disabled="disabled"> + {{$t('general.confirm')}} + </button> + <button class="btn btn-default" @click="cancel" :disabled="disabled"> + {{$t('general.cancel')}} + </button> +</div> +</template> + +<script src="./confirm.js"> +</script> diff --git a/src/components/user_settings/mfa.js b/src/components/user_settings/mfa.js new file mode 100644 index 00000000..2acee862 --- /dev/null +++ b/src/components/user_settings/mfa.js @@ -0,0 +1,152 @@ +import RecoveryCodes from './mfa_backup_codes.vue' +import TOTP from './mfa_totp.vue' +import Confirm from './confirm.vue' +import VueQrcode from '@chenfengyuan/vue-qrcode' +import { mapState } from 'vuex' + +const Mfa = { + data: () => ({ + settings: { // current settings of MFA + enabled: false, + totp: false + }, + setupState: { // setup mfa + state: '', // state of setup. '' -> 'getBackupCodes' -> 'setupOTP' -> 'complete' + setupOTPState: '' // state of setup otp. '' -> 'prepare' -> 'confirm' -> 'complete' + }, + backupCodes: { + getNewCodes: false, + inProgress: false, // progress of fetch codes + codes: [] + }, + otpSettings: { // pre-setup setting of OTP. secret key, qrcode url. + provisioning_uri: '', + key: '' + }, + currentPassword: null, + otpConfirmToken: null, + error: null, + readyInit: false + }), + components: { + 'recovery-codes': RecoveryCodes, + 'totp-item': TOTP, + 'qrcode': VueQrcode, + 'confirm': Confirm + }, + computed: { + canSetupOTP () { + return ( + (this.setupInProgress && this.backupCodesPrepared) || + this.settings.enabled + ) && !this.settings.totp && !this.setupOTPInProgress + }, + setupInProgress () { + return this.setupState.state !== '' && this.setupState.state !== 'complete' + }, + setupOTPInProgress () { + return this.setupState.state === 'setupOTP' && !this.completedOTP + }, + prepareOTP () { + return this.setupState.setupOTPState === 'prepare' + }, + confirmOTP () { + return this.setupState.setupOTPState === 'confirm' + }, + completedOTP () { + return this.setupState.setupOTPState === 'completed' + }, + backupCodesPrepared () { + return !this.backupCodes.inProgress && this.backupCodes.codes.length > 0 + }, + confirmNewBackupCodes () { + return this.backupCodes.getNewCodes + }, + ...mapState({ + backendInteractor: (state) => state.api.backendInteractor + }) + }, + + methods: { + activateOTP () { + if (!this.settings.enabled) { + this.setupState.state = 'getBackupcodes' + this.fetchBackupCodes() + } + }, + fetchBackupCodes () { + this.backupCodes.inProgress = true + this.backupCodes.codes = [] + + return this.backendInteractor.generateMfaBackupCodes() + .then((res) => { + this.backupCodes.codes = res.codes + this.backupCodes.inProgress = false + }) + }, + getBackupCodes () { // get a new backup codes + this.backupCodes.getNewCodes = true + }, + confirmBackupCodes () { // confirm getting new backup codes + this.fetchBackupCodes().then((res) => { + this.backupCodes.getNewCodes = false + }) + }, + cancelBackupCodes () { // cancel confirm form of new backup codes + this.backupCodes.getNewCodes = false + }, + + // Setup OTP + setupOTP () { // prepare setup OTP + this.setupState.state = 'setupOTP' + this.setupState.setupOTPState = 'prepare' + this.backendInteractor.mfaSetupOTP() + .then((res) => { + this.otpSettings = res + this.setupState.setupOTPState = 'confirm' + }) + }, + doConfirmOTP () { // handler confirm enable OTP + this.error = null + this.backendInteractor.mfaConfirmOTP({ + token: this.otpConfirmToken, + password: this.currentPassword + }) + .then((res) => { + if (res.error) { + this.error = res.error + return + } + this.completeSetup() + }) + }, + + completeSetup () { + this.setupState.setupOTPState = 'complete' + this.setupState.state = 'complete' + this.currentPassword = null + this.error = null + this.fetchSettings() + }, + cancelSetup () { // cancel setup + this.setupState.setupOTPState = '' + this.setupState.state = '' + this.currentPassword = null + this.error = null + }, + // end Setup OTP + + // fetch settings from server + async fetchSettings () { + let result = await this.backendInteractor.fetchSettingsMFA() + this.settings = result.settings + return result + } + }, + mounted () { + this.fetchSettings().then(() => { + this.readyInit = true + }) + } +} +export default Mfa diff --git a/src/components/user_settings/mfa.vue b/src/components/user_settings/mfa.vue new file mode 100644 index 00000000..ded426dd --- /dev/null +++ b/src/components/user_settings/mfa.vue @@ -0,0 +1,121 @@ +<template> +<div class="setting-item mfa-settings" v-if="readyInit"> + + <div class="mfa-heading"> + <h2>{{$t('settings.mfa.title')}}</h2> + </div> + + <div> + <div class="setting-item" v-if="!setupInProgress"> + <!-- Enabled methods --> + <h3>{{$t('settings.mfa.authentication_methods')}}</h3> + <totp-item :settings="settings" @deactivate="fetchSettings" @activate="activateOTP"/> + <br /> + + <div v-if="settings.enabled"> <!-- backup codes block--> + <recovery-codes :backup-codes="backupCodes" v-if="!confirmNewBackupCodes" /> + <button class="btn btn-default" @click="getBackupCodes" v-if="!confirmNewBackupCodes"> + {{$t('settings.mfa.generate_new_recovery_codes')}} + </button> + + <div v-if="confirmNewBackupCodes"> + <confirm @confirm="confirmBackupCodes" @cancel="cancelBackupCodes" + :disabled="backupCodes.inProgress"> + <p class="warning">{{$t('settings.mfa.warning_of_generate_new_codes')}}</p> + </confirm> + </div> + </div> + </div> + + <div v-if="setupInProgress"> <!-- setup block--> + + <h3>{{$t('settings.mfa.setup_otp')}}</h3> + + <recovery-codes :backup-codes="backupCodes" v-if="!setupOTPInProgress"/> + + + <button class="btn btn-default" @click="cancelSetup" v-if="canSetupOTP"> + {{$t('general.cancel')}} + </button> + + <button class="btn btn-default" v-if="canSetupOTP" @click="setupOTP"> + {{$t('settings.mfa.setup_otp')}} + </button> + + <template v-if="setupOTPInProgress"> + <i v-if="prepareOTP">{{$t('settings.mfa.wait_pre_setup_otp')}}</i> + + <div v-if="confirmOTP"> + <div class="setup-otp"> + <div class="qr-code"> + <h4>{{$t('settings.mfa.scan.title')}}</h4> + <p>{{$t('settings.mfa.scan.desc')}}</p> + <qrcode :value="otpSettings.provisioning_uri" :options="{ width: 200 }"></qrcode> + <p> + {{$t('settings.mfa.scan.secret_code')}}: + {{otpSettings.key}} + </p> + </div> + + <div class="verify"> + <h4>{{$t('general.verify')}}</h4> + <p>{{$t('settings.mfa.verify.desc')}}</p> + <input type="text" v-model="otpConfirmToken"> + + <p>{{$t('settings.enter_current_password_to_confirm')}}:</p> + <input type="password" v-model="currentPassword"> + <div class="confirm-otp-actions"> + <button class="btn btn-default" @click="doConfirmOTP"> + {{$t('settings.mfa.confirm_and_enable')}} + </button> + <button class="btn btn-default" @click="cancelSetup"> + {{$t('general.cancel')}} + </button> + </div> + <div class="alert error" v-if="error">{{error}}</div> + </div> + </div> + </div> + </template> + </div> + + </div> +</div> +</template> + +<script src="./mfa.js"></script> +<style lang="scss"> +@import '../../_variables.scss'; +.warning { + color: $fallback--cOrange; + color: var(--cOrange, $fallback--cOrange); +} +.mfa-settings { + .mfa-heading, .method-item { + overflow: hidden; + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: baseline; + } + + .setup-otp { + display: flex; + justify-content: center; + flex-wrap: wrap; + .qr-code { + flex: 1; + padding-right: 10px; + } + .verify { flex: 1; } + .error { margin: 4px 0 0 0; } + .confirm-otp-actions { + button { + width: 15em; + margin-top: 5px; + } + + } + } +} +</style> diff --git a/src/components/user_settings/mfa_backup_codes.js b/src/components/user_settings/mfa_backup_codes.js new file mode 100644 index 00000000..f0a984ec --- /dev/null +++ b/src/components/user_settings/mfa_backup_codes.js @@ -0,0 +1,17 @@ +export default { + props: { + backupCodes: { + type: Object, + default: () => ({ + inProgress: false, + codes: [] + }) + } + }, + data: () => ({}), + computed: { + inProgress () { return this.backupCodes.inProgress }, + ready () { return this.backupCodes.codes.length > 0 }, + displayTitle () { return this.inProgress || this.ready } + } +} diff --git a/src/components/user_settings/mfa_backup_codes.vue b/src/components/user_settings/mfa_backup_codes.vue new file mode 100644 index 00000000..c275bd63 --- /dev/null +++ b/src/components/user_settings/mfa_backup_codes.vue @@ -0,0 +1,22 @@ +<template> +<div> + <h4 v-if="displayTitle">{{$t('settings.mfa.recovery_codes')}}</h4> + <i v-if="inProgress">{{$t('settings.mfa.waiting_a_recovery_codes')}}</i> + <template v-if="ready"> + <p class="alert warning">{{$t('settings.mfa.recovery_codes_warning')}}</p> + <ul class="backup-codes"><li v-for="code in backupCodes.codes">{{code}}</li></ul> + </template> +</div> +</template> +<script src="./mfa_backup_codes.js"></script> +<style lang="scss"> +@import '../../_variables.scss'; + +.warning { + color: $fallback--cOrange; + color: var(--cOrange, $fallback--cOrange); +} +.backup-codes { + font-family: var(--postCodeFont, monospace); +} +</style> diff --git a/src/components/user_settings/mfa_totp.js b/src/components/user_settings/mfa_totp.js new file mode 100644 index 00000000..8408d8e9 --- /dev/null +++ b/src/components/user_settings/mfa_totp.js @@ -0,0 +1,49 @@ +import Confirm from './confirm.vue' +import { mapState } from 'vuex' + +export default { + props: ['settings'], + data: () => ({ + error: false, + currentPassword: '', + deactivate: false, + inProgress: false // progress peform request to disable otp method + }), + components: { + 'confirm': Confirm + }, + computed: { + isActivated () { + return this.settings.totp + }, + ...mapState({ + backendInteractor: (state) => state.api.backendInteractor + }) + }, + methods: { + doActivate () { + this.$emit('activate') + }, + cancelDeactivate () { this.deactivate = false }, + doDeactivate () { + this.error = null + this.deactivate = true + }, + confirmDeactivate () { // confirm deactivate TOTP method + this.error = null + this.inProgress = true + this.backendInteractor.mfaDisableOTP({ + password: this.currentPassword + }) + .then((res) => { + this.inProgress = false + if (res.error) { + this.error = res.error + return + } + this.deactivate = false + this.$emit('deactivate') + }) + } + } +} diff --git a/src/components/user_settings/mfa_totp.vue b/src/components/user_settings/mfa_totp.vue new file mode 100644 index 00000000..6b73c8f4 --- /dev/null +++ b/src/components/user_settings/mfa_totp.vue @@ -0,0 +1,23 @@ +<template> +<div> + <div class="method-item"> + <strong>{{$t('settings.mfa.otp')}}</strong> + <button class="btn btn-default" v-if="!isActivated" @click="doActivate"> + {{$t('general.enable')}} + </button> + + <button class="btn btn-default" :disabled="deactivate" @click="doDeactivate" + v-if="isActivated"> + {{$t('general.disable')}} + </button> + </div> + + <confirm @confirm="confirmDeactivate" @cancel="cancelDeactivate" + :disabled="inProgress" v-if="deactivate"> + {{$t('settings.enter_current_password_to_confirm')}}: + <input type="password" v-model="currentPassword"> + </confirm> + <div class="alert error" v-if="error">{{error}}</div> +</div> +</template> +<script src="./mfa_totp.js"></script> diff --git a/src/components/user_settings/user_settings.js b/src/components/user_settings/user_settings.js index 5cb23b97..69505806 100644 --- a/src/components/user_settings/user_settings.js +++ b/src/components/user_settings/user_settings.js @@ -1,33 +1,35 @@ -import { compose } from 'vue-compose' import unescape from 'lodash/unescape' import get from 'lodash/get' +import map from 'lodash/map' +import reject from 'lodash/reject' import TabSwitcher from '../tab_switcher/tab_switcher.js' import ImageCropper from '../image_cropper/image_cropper.vue' import StyleSwitcher from '../style_switcher/style_switcher.vue' +import ScopeSelector from '../scope_selector/scope_selector.vue' import fileSizeFormatService from '../../services/file_size_format/file_size_format.js' import BlockCard from '../block_card/block_card.vue' import MuteCard from '../mute_card/mute_card.vue' +import SelectableList from '../selectable_list/selectable_list.vue' +import ProgressButton from '../progress_button/progress_button.vue' import EmojiInput from '../emoji-input/emoji-input.vue' +import Autosuggest from '../autosuggest/autosuggest.vue' +import Importer from '../importer/importer.vue' +import Exporter from '../exporter/exporter.vue' import withSubscription from '../../hocs/with_subscription/with_subscription' -import withList from '../../hocs/with_list/with_list' +import userSearchApi from '../../services/new_api/user_search.js' +import Mfa from './mfa.vue' -const BlockList = compose( - withSubscription({ - fetch: (props, $store) => $store.dispatch('fetchBlocks'), - select: (props, $store) => get($store.state.users.currentUser, 'blockIds', []), - childPropName: 'entries' - }), - withList({ getEntryProps: userId => ({ userId }) }) -)(BlockCard) +const BlockList = withSubscription({ + fetch: (props, $store) => $store.dispatch('fetchBlocks'), + select: (props, $store) => get($store.state.users.currentUser, 'blockIds', []), + childPropName: 'items' +})(SelectableList) -const MuteList = compose( - withSubscription({ - fetch: (props, $store) => $store.dispatch('fetchMutes'), - select: (props, $store) => get($store.state.users.currentUser, 'muteIds', []), - childPropName: 'entries' - }), - withList({ getEntryProps: userId => ({ userId }) }) -)(MuteCard) +const MuteList = withSubscription({ + fetch: (props, $store) => $store.dispatch('fetchMutes'), + select: (props, $store) => get($store.state.users.currentUser, 'muteIds', []), + childPropName: 'items' +})(SelectableList) const UserSettings = { data () { @@ -41,14 +43,9 @@ const UserSettings = { hideFollowers: this.$store.state.users.currentUser.hide_followers, showRole: this.$store.state.users.currentUser.show_role, role: this.$store.state.users.currentUser.role, - followList: null, - followImportError: false, - followsImported: false, - enableFollowsExport: true, pickAvatarBtnVisible: true, bannerUploading: false, backgroundUploading: false, - followListUploading: false, bannerPreview: null, backgroundPreview: null, bannerUploadError: null, @@ -59,7 +56,8 @@ const UserSettings = { changePasswordInputs: [ '', '', '' ], changedPassword: false, changePasswordError: false, - activeTab: 'profile' + activeTab: 'profile', + notificationSettings: this.$store.state.users.currentUser.notification_settings } }, created () { @@ -67,11 +65,19 @@ const UserSettings = { }, components: { StyleSwitcher, + ScopeSelector, TabSwitcher, ImageCropper, BlockList, MuteList, - EmojiInput + EmojiInput, + Autosuggest, + BlockCard, + MuteCard, + ProgressButton, + Importer, + Exporter, + Mfa }, computed: { user () { @@ -80,8 +86,8 @@ const UserSettings = { pleromaBackend () { return this.$store.state.instance.pleromaBackend }, - scopeOptionsEnabled () { - return this.$store.state.instance.scopeOptionsEnabled + minimalScopesMode () { + return this.$store.state.instance.minimalScopesMode }, vis () { return { @@ -106,39 +112,29 @@ const UserSettings = { }, methods: { updateProfile () { - const name = this.newName - const description = this.newBio - const locked = this.newLocked - // Backend notation. - /* eslint-disable camelcase */ - const default_scope = this.newDefaultScope - const no_rich_text = this.newNoRichText - const hide_follows = this.hideFollows - const hide_followers = this.hideFollowers - const show_role = this.showRole - - /* eslint-enable camelcase */ this.$store.state.api.backendInteractor .updateProfile({ params: { - name, - description, - locked, + note: this.newBio, + locked: this.newLocked, // Backend notation. /* eslint-disable camelcase */ - default_scope, - no_rich_text, - hide_follows, - hide_followers, - show_role + display_name: this.newName, + default_scope: this.newDefaultScope, + no_rich_text: this.newNoRichText, + hide_follows: this.hideFollows, + hide_followers: this.hideFollowers, + show_role: this.showRole /* eslint-enable camelcase */ }}).then((user) => { - if (!user.error) { - this.$store.commit('addNewUsers', [user]) - this.$store.commit('setCurrentUser', user) - } + this.$store.commit('addNewUsers', [user]) + this.$store.commit('setCurrentUser', user) }) }, + updateNotificationSettings () { + this.$store.state.api.backendInteractor + .updateNotificationSettings({ settings: this.notificationSettings }) + }, changeVis (visibility) { this.newDefaultScope = visibility }, @@ -156,23 +152,29 @@ const UserSettings = { reader.onload = ({target}) => { const img = target.result this[slot + 'Preview'] = img + this[slot] = file } reader.readAsDataURL(file) }, submitAvatar (cropper, file) { - let img - if (cropper) { - img = cropper.getCroppedCanvas().toDataURL(file.type) - } else { - img = file - } + const that = this + return new Promise((resolve, reject) => { + function updateAvatar (avatar) { + that.$store.state.api.backendInteractor.updateAvatar({ avatar }) + .then((user) => { + that.$store.commit('addNewUsers', [user]) + that.$store.commit('setCurrentUser', user) + resolve() + }) + .catch((err) => { + reject(new Error(that.$t('upload.error.base') + ' ' + err.message)) + }) + } - return this.$store.state.api.backendInteractor.updateAvatar({ params: { img } }).then((user) => { - if (!user.error) { - this.$store.commit('addNewUsers', [user]) - this.$store.commit('setCurrentUser', user) + if (cropper) { + cropper.getCroppedCanvas().toBlob(updateAvatar, file.type) } else { - throw new Error(this.$t('upload.error.base') + user.error) + updateAvatar(file) } }) }, @@ -182,30 +184,17 @@ const UserSettings = { submitBanner () { if (!this.bannerPreview) { return } - let banner = this.bannerPreview - // eslint-disable-next-line no-undef - let imginfo = new Image() - /* eslint-disable camelcase */ - let offset_top, offset_left, width, height - imginfo.src = banner - width = imginfo.width - height = imginfo.height - offset_top = 0 - offset_left = 0 this.bannerUploading = true - this.$store.state.api.backendInteractor.updateBanner({params: {banner, offset_top, offset_left, width, height}}).then((data) => { - if (!data.error) { - let clone = JSON.parse(JSON.stringify(this.$store.state.users.currentUser)) - clone.cover_photo = data.url - this.$store.commit('addNewUsers', [clone]) - this.$store.commit('setCurrentUser', clone) + this.$store.state.api.backendInteractor.updateBanner({banner: this.banner}) + .then((user) => { + this.$store.commit('addNewUsers', [user]) + this.$store.commit('setCurrentUser', user) this.bannerPreview = null - } else { - this.bannerUploadError = this.$t('upload.error.base') + data.error - } - this.bannerUploading = false - }) - /* eslint-enable camelcase */ + }) + .catch((err) => { + this.bannerUploadError = this.$t('upload.error.base') + ' ' + err.message + }) + .then(() => { this.bannerUploading = false }) }, submitBg () { if (!this.backgroundPreview) { return } @@ -232,62 +221,41 @@ const UserSettings = { this.backgroundUploading = false }) }, - importFollows () { - this.followListUploading = true - const followList = this.followList - this.$store.state.api.backendInteractor.followImport({params: followList}) + importFollows (file) { + return this.$store.state.api.backendInteractor.importFollows(file) .then((status) => { - if (status) { - this.followsImported = true - } else { - this.followImportError = true + if (!status) { + throw new Error('failed') } - this.followListUploading = false }) }, - /* This function takes an Array of Users - * and outputs a file with all the addresses for the user to download - */ - exportPeople (users, filename) { - // Get all the friends addresses - var UserAddresses = users.map(function (user) { + importBlocks (file) { + return this.$store.state.api.backendInteractor.importBlocks(file) + .then((status) => { + if (!status) { + throw new Error('failed') + } + }) + }, + generateExportableUsersContent (users) { + // Get addresses + return users.map((user) => { // check is it's a local user if (user && user.is_local) { // append the instance address // eslint-disable-next-line no-undef - user.screen_name += '@' + location.hostname + return user.screen_name + '@' + location.hostname } return user.screen_name }).join('\n') - // Make the user download the file - var fileToDownload = document.createElement('a') - fileToDownload.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(UserAddresses)) - fileToDownload.setAttribute('download', filename) - fileToDownload.style.display = 'none' - document.body.appendChild(fileToDownload) - fileToDownload.click() - document.body.removeChild(fileToDownload) - }, - exportFollows () { - this.enableFollowsExport = false - this.$store.state.api.backendInteractor - .exportFriends({ - id: this.$store.state.users.currentUser.id - }) - .then((friendList) => { - this.exportPeople(friendList, 'friends.csv') - setTimeout(() => { this.enableFollowsExport = true }, 2000) - }) }, - followListChange () { - // eslint-disable-next-line no-undef - let formData = new FormData() - formData.append('list', this.$refs.followlist.files[0]) - this.followList = formData + getFollowsContent () { + return this.$store.state.api.backendInteractor.exportFriends({ id: this.$store.state.users.currentUser.id }) + .then(this.generateExportableUsersContent) }, - dismissImported () { - this.followsImported = false - this.followImportError = false + getBlocksContent () { + return this.$store.state.api.backendInteractor.fetchBlocks() + .then(this.generateExportableUsersContent) }, confirmDelete () { this.deletingAccount = true @@ -332,6 +300,40 @@ const UserSettings = { if (window.confirm(`${this.$i18n.t('settings.revoke_token')}?`)) { this.$store.dispatch('revokeToken', id) } + }, + filterUnblockedUsers (userIds) { + return reject(userIds, (userId) => { + const user = this.$store.getters.findUser(userId) + return !user || user.statusnet_blocking || user.id === this.$store.state.users.currentUser.id + }) + }, + filterUnMutedUsers (userIds) { + return reject(userIds, (userId) => { + const user = this.$store.getters.findUser(userId) + return !user || user.muted || user.id === this.$store.state.users.currentUser.id + }) + }, + queryUserIds (query) { + return userSearchApi.search({query, store: this.$store}) + .then((users) => { + this.$store.dispatch('addNewUsers', users) + return map(users, 'id') + }) + }, + blockUsers (ids) { + return this.$store.dispatch('blockUsers', ids) + }, + unblockUsers (ids) { + return this.$store.dispatch('unblockUsers', ids) + }, + muteUsers (ids) { + return this.$store.dispatch('muteUsers', ids) + }, + unmuteUsers (ids) { + return this.$store.dispatch('unmuteUsers', ids) + }, + identity (value) { + return value } } } diff --git a/src/components/user_settings/user_settings.vue b/src/components/user_settings/user_settings.vue index 52df143c..bbe41f11 100644 --- a/src/components/user_settings/user_settings.vue +++ b/src/components/user_settings/user_settings.vue @@ -22,7 +22,7 @@ <div class="setting-item" > <h2>{{$t('settings.name_bio')}}</h2> <p>{{$t('settings.name')}}</p> - <EmojiInput + <EmojiInput type="text" v-model="newName" id="username" @@ -38,13 +38,14 @@ <input type="checkbox" v-model="newLocked" id="account-locked"> <label for="account-locked">{{$t('settings.lock_account_description')}}</label> </p> - <div v-if="scopeOptionsEnabled"> + <div> <label for="default-vis">{{$t('settings.default_vis')}}</label> <div id="default-vis" class="visibility-tray"> - <i v-on:click="changeVis('direct')" class="icon-mail-alt" :class="vis.direct" :title="$t('post_status.scope.direct')" ></i> - <i v-on:click="changeVis('private')" class="icon-lock" :class="vis.private" :title="$t('post_status.scope.private')"></i> - <i v-on:click="changeVis('unlisted')" class="icon-lock-open-alt" :class="vis.unlisted" :title="$t('post_status.scope.unlisted')"></i> - <i v-on:click="changeVis('public')" class="icon-globe" :class="vis.public" :title="$t('post_status.scope.public')"></i> + <scope-selector + :showAll="true" + :userDefault="newDefaultScope" + :initialScope="newDefaultScope" + :onScopeChange="changeVis"/> </div> </div> <p> @@ -151,7 +152,7 @@ </tbody> </table> </div> - + <mfa /> <div class="setting-item"> <h2>{{$t('settings.delete_account')}}</h2> <p v-if="!deletingAccount">{{$t('settings.delete_account_description')}}</p> @@ -167,43 +168,110 @@ </div> </div> + <div :label="$t('settings.notifications')" v-if="pleromaBackend"> + <div class="setting-item"> + <div class="select-multiple"> + <span class="label">{{$t('settings.notification_setting')}}</span> + <ul class="option-list"> + <li> + <input type="checkbox" id="notification-setting-follows" v-model="notificationSettings.follows"> + <label for="notification-setting-follows"> + {{$t('settings.notification_setting_follows')}} + </label> + </li> + <li> + <input type="checkbox" id="notification-setting-followers" v-model="notificationSettings.followers"> + <label for="notification-setting-followers"> + {{$t('settings.notification_setting_followers')}} + </label> + </li> + <li> + <input type="checkbox" id="notification-setting-non-follows" v-model="notificationSettings.non_follows"> + <label for="notification-setting-non-follows"> + {{$t('settings.notification_setting_non_follows')}} + </label> + </li> + <li> + <input type="checkbox" id="notification-setting-non-followers" v-model="notificationSettings.non_followers"> + <label for="notification-setting-non-followers"> + {{$t('settings.notification_setting_non_followers')}} + </label> + </li> + </ul> + </div> + <p>{{$t('settings.notification_mutes')}}</p> + <p>{{$t('settings.notification_blocks')}}</p> + <button class="btn btn-default" @click="updateNotificationSettings">{{$t('general.submit')}}</button> + </div> + </div> + <div :label="$t('settings.data_import_export_tab')" v-if="pleromaBackend"> <div class="setting-item"> <h2>{{$t('settings.follow_import')}}</h2> <p>{{$t('settings.import_followers_from_a_csv_file')}}</p> - <form> - <input type="file" ref="followlist" v-on:change="followListChange" /> - </form> - <i class=" icon-spin4 animate-spin uploading" v-if="followListUploading"></i> - <button class="btn btn-default" v-else @click="importFollows">{{$t('general.submit')}}</button> - <div v-if="followsImported"> - <i class="icon-cross" @click="dismissImported"></i> - <p>{{$t('settings.follows_imported')}}</p> - </div> - <div v-else-if="followImportError"> - <i class="icon-cross" @click="dismissImported"></i> - <p>{{$t('settings.follow_import_error')}}</p> - </div> + <Importer :submitHandler="importFollows" :successMessage="$t('settings.follows_imported')" :errorMessage="$t('settings.follow_import_error')" /> </div> - <div class="setting-item" v-if="enableFollowsExport"> + <div class="setting-item"> <h2>{{$t('settings.follow_export')}}</h2> - <button class="btn btn-default" @click="exportFollows">{{$t('settings.follow_export_button')}}</button> + <Exporter :getContent="getFollowsContent" filename="friends.csv" :exportButtonLabel="$t('settings.follow_export_button')" /> + </div> + <div class="setting-item"> + <h2>{{$t('settings.block_import')}}</h2> + <p>{{$t('settings.import_blocks_from_a_csv_file')}}</p> + <Importer :submitHandler="importBlocks" :successMessage="$t('settings.blocks_imported')" :errorMessage="$t('settings.block_import_error')" /> </div> - <div class="setting-item" v-else> - <h2>{{$t('settings.follow_export_processing')}}</h2> + <div class="setting-item"> + <h2>{{$t('settings.block_export')}}</h2> + <Exporter :getContent="getBlocksContent" filename="blocks.csv" :exportButtonLabel="$t('settings.block_export_button')" /> </div> </div> <div :label="$t('settings.blocks_tab')"> - <block-list :refresh="true"> + <div class="profile-edit-usersearch-wrapper"> + <Autosuggest :filter="filterUnblockedUsers" :query="queryUserIds" :placeholder="$t('settings.search_user_to_block')"> + <BlockCard slot-scope="row" :userId="row.item"/> + </Autosuggest> + </div> + <BlockList :refresh="true" :getKey="identity"> + <template slot="header" slot-scope="{selected}"> + <div class="profile-edit-bulk-actions"> + <ProgressButton class="btn btn-default" v-if="selected.length > 0" :click="() => blockUsers(selected)"> + {{ $t('user_card.block') }} + <template slot="progress">{{ $t('user_card.block_progress') }}</template> + </ProgressButton> + <ProgressButton class="btn btn-default" v-if="selected.length > 0" :click="() => unblockUsers(selected)"> + {{ $t('user_card.unblock') }} + <template slot="progress">{{ $t('user_card.unblock_progress') }}</template> + </ProgressButton> + </div> + </template> + <template slot="item" slot-scope="{item}"><BlockCard :userId="item" /></template> <template slot="empty">{{$t('settings.no_blocks')}}</template> - </block-list> + </BlockList> </div> <div :label="$t('settings.mutes_tab')"> - <mute-list :refresh="true"> + <div class="profile-edit-usersearch-wrapper"> + <Autosuggest :filter="filterUnMutedUsers" :query="queryUserIds" :placeholder="$t('settings.search_user_to_mute')"> + <MuteCard slot-scope="row" :userId="row.item"/> + </Autosuggest> + </div> + <MuteList :refresh="true" :getKey="identity"> + <template slot="header" slot-scope="{selected}"> + <div class="profile-edit-bulk-actions"> + <ProgressButton class="btn btn-default" v-if="selected.length > 0" :click="() => muteUsers(selected)"> + {{ $t('user_card.mute') }} + <template slot="progress">{{ $t('user_card.mute_progress') }}</template> + </ProgressButton> + <ProgressButton class="btn btn-default" v-if="selected.length > 0" :click="() => unmuteUsers(selected)"> + {{ $t('user_card.unmute') }} + <template slot="progress">{{ $t('user_card.unmute_progress') }}</template> + </ProgressButton> + </div> + </template> + <template slot="item" slot-scope="{item}"><MuteCard :userId="item" /></template> <template slot="empty">{{$t('settings.no_mutes')}}</template> - </mute-list> + </MuteList> </div> </tab-switcher> </div> @@ -221,6 +289,10 @@ margin: 0; } + .visibility-tray { + padding-top: 5px; + } + input[type=file] { padding: 5px; height: auto; @@ -262,5 +334,19 @@ text-align: right; } } + + &-usersearch-wrapper { + padding: 1em; + } + + &-bulk-actions { + text-align: right; + padding: 0 1em; + min-height: 28px; + + button { + width: 10em; + } + } } </style> diff --git a/src/components/who_to_follow/who_to_follow.js b/src/components/who_to_follow/who_to_follow.js index be0b8827..7ae602a2 100644 --- a/src/components/who_to_follow/who_to_follow.js +++ b/src/components/who_to_follow/who_to_follow.js @@ -20,7 +20,8 @@ const WhoToFollow = { id: 0, name: i.display_name, screen_name: i.acct, - profile_image_url: i.avatar || '/images/avi.png' + profile_image_url: i.avatar || '/images/avi.png', + profile_image_url_original: i.avatar || '/images/avi.png' } this.users.push(user) diff --git a/src/components/who_to_follow/who_to_follow.vue b/src/components/who_to_follow/who_to_follow.vue index 1630f5ac..8bc9a728 100644 --- a/src/components/who_to_follow/who_to_follow.vue +++ b/src/components/who_to_follow/who_to_follow.vue @@ -4,7 +4,7 @@ {{$t('who_to_follow.who_to_follow')}} </div> <div class="panel-body"> - <FollowCard v-for="user in users" :key="user.id" :user="user"/> + <FollowCard v-for="user in users" :key="user.id" :user="user" class="list-item"/> </div> </div> </template> diff --git a/src/components/who_to_follow_panel/who_to_follow_panel.vue b/src/components/who_to_follow_panel/who_to_follow_panel.vue index 25e3a9f6..74e82789 100644 --- a/src/components/who_to_follow_panel/who_to_follow_panel.vue +++ b/src/components/who_to_follow_panel/who_to_follow_panel.vue @@ -6,14 +6,18 @@ {{$t('who_to_follow.who_to_follow')}} </div> </div> - <div class="panel-body who-to-follow"> - <span v-for="user in usersToFollow"> + <div class="who-to-follow"> + <p v-for="user in usersToFollow" class="who-to-follow-items"> <img v-bind:src="user.img" /> <router-link v-bind:to="userProfileLink(user.id, user.name)"> {{user.name}} </router-link><br /> - </span> - <img v-bind:src="$store.state.instance.logo"> <router-link :to="{ name: 'who-to-follow' }">{{$t('who_to_follow.more')}}</router-link> + </p> + <p class="who-to-follow-more"> + <router-link :to="{ name: 'who-to-follow' }"> + {{$t('who_to_follow.more')}} + </router-link> + </p> </div> </div> </div> @@ -30,11 +34,19 @@ height: 32px; } .who-to-follow { - padding: 0.5em 1em 0.5em 1em; + padding: 0em 1em; margin: 0px; - line-height: 40px; + } + .who-to-follow-items { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + padding: 0px; + margin: 1em 0em; + } + .who-to-follow-more { + padding: 0px; + margin: 1em 0em; + text-align: center; } </style> diff --git a/src/hocs/with_list/with_list.js b/src/hocs/with_list/with_list.js deleted file mode 100644 index 896f8fc8..00000000 --- a/src/hocs/with_list/with_list.js +++ /dev/null @@ -1,40 +0,0 @@ -import Vue from 'vue' -import map from 'lodash/map' -import isEmpty from 'lodash/isEmpty' -import './with_list.scss' - -const defaultEntryPropsGetter = entry => ({ entry }) -const defaultKeyGetter = entry => entry.id - -const withList = ({ - getEntryProps = defaultEntryPropsGetter, // function to accept entry and index values and return props to be passed into the item component - getKey = defaultKeyGetter // funciton to accept entry and index values and return key prop value -}) => (ItemComponent) => ( - Vue.component('withList', { - props: [ - 'entries', // array of entry - 'entryProps', // additional props to be passed into each entry - 'entryListeners' // additional event listeners to be passed into each entry - ], - render (createElement) { - return ( - <div class="with-list"> - {map(this.entries, (entry, index) => { - const props = { - key: getKey(entry, index), - props: { - ...this.$props.entryProps, - ...getEntryProps(entry, index) - }, - on: this.$props.entryListeners - } - return <ItemComponent {...props} /> - })} - {isEmpty(this.entries) && this.$slots.empty && <div class="with-list-empty-content faint">{this.$slots.empty}</div>} - </div> - ) - } - }) -) - -export default withList diff --git a/src/hocs/with_list/with_list.scss b/src/hocs/with_list/with_list.scss deleted file mode 100644 index c6e13d5b..00000000 --- a/src/hocs/with_list/with_list.scss +++ /dev/null @@ -1,6 +0,0 @@ -.with-list { - &-empty-content { - text-align: center; - padding: 10px; - } -}
\ No newline at end of file diff --git a/src/hocs/with_load_more/with_load_more.scss b/src/hocs/with_load_more/with_load_more.scss index 1a0a9c40..4cefe2be 100644 --- a/src/hocs/with_load_more/with_load_more.scss +++ b/src/hocs/with_load_more/with_load_more.scss @@ -1,10 +1,16 @@ + +@import '../../_variables.scss'; + .with-load-more { &-footer { padding: 10px; text-align: center; + border-top: 1px solid; + border-top-color: $fallback--border; + border-top-color: var(--border, $fallback--border); .error { font-size: 14px; } } -}
\ No newline at end of file +} diff --git a/src/i18n/compare.js b/src/i18n/compare index e9314376..4dc1e47d 100755 --- a/src/i18n/compare.js +++ b/src/i18n/compare @@ -19,7 +19,7 @@ if (typeof arg === 'undefined') { console.log('') console.log('There are no other arguments or options. Make an issue if you encounter a bug or want') console.log('some feature to be implemented. Merge requests are welcome as well.') - return + process.exit() } const english = require('./en.json') diff --git a/src/i18n/cs.json b/src/i18n/cs.json index 020092a6..5f2f2b71 100644 --- a/src/i18n/cs.json +++ b/src/i18n/cs.json @@ -73,7 +73,8 @@ "content_type": { "text/plain": "Prostý text", "text/html": "HTML", - "text/markdown": "Markdown" + "text/markdown": "Markdown", + "text/bbcode": "BBCode" }, "content_warning": "Předmět (volitelný)", "default": "Právě jsem přistál v L.A.", diff --git a/src/i18n/en.json b/src/i18n/en.json index c501c6a7..a29f394b 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -2,6 +2,10 @@ "chat": { "title": "Chat" }, + "exporter": { + "export": "Export", + "processing": "Processing, you'll soon be asked to download your file" + }, "features_panel": { "chat": "Chat", "gopher": "Gopher", @@ -20,7 +24,14 @@ "submit": "Submit", "more": "More", "generic_error": "An error occured", - "optional": "optional" + "optional": "optional", + "show_more": "Show more", + "show_less": "Show less", + "cancel": "Cancel", + "disable": "Disable", + "enable": "Enable", + "confirm": "Confirm", + "verify": "Verify" }, "image_cropper": { "crop_picture": "Crop picture", @@ -28,6 +39,11 @@ "save_without_cropping": "Save without cropping", "cancel": "Cancel" }, + "importer": { + "submit": "Submit", + "success": "Imported successfully.", + "error": "An error occured while importing this file." + }, "login": { "login": "Log in", "description": "Log in with OAuth", @@ -36,7 +52,15 @@ "placeholder": "e.g. lain", "register": "Register", "username": "Username", - "hint": "Log in to join the discussion" + "hint": "Log in to join the discussion", + "authentication_code": "Authentication code", + "enter_recovery_code": "Enter a recovery code", + "enter_two_factor_code": "Enter a two-factor code", + "recovery_code": "Recovery code", + "heading" : { + "totp" : "Two-factor authentication", + "recovery" : "Two-factor recovery" + } }, "media_modal": { "previous": "Previous", @@ -48,6 +72,7 @@ "chat": "Local Chat", "friend_requests": "Follow Requests", "mentions": "Mentions", + "interactions": "Interactions", "dms": "Direct Messages", "public_tl": "Public Timeline", "timeline": "Timeline", @@ -66,6 +91,11 @@ "repeated_you": "repeated your status", "no_more_notifications": "No more notifications" }, + "interactions": { + "favs_repeats": "Repeats and Favorites", + "follows": "New follows", + "load_older": "Load older interactions" + }, "post_status": { "new_status": "Post new status", "account_not_locked_warning": "Your account is not {0}. Anyone can follow you to view your follower-only posts.", @@ -74,12 +104,19 @@ "content_type": { "text/plain": "Plain text", "text/html": "HTML", - "text/markdown": "Markdown" + "text/markdown": "Markdown", + "text/bbcode": "BBCode" }, "content_warning": "Subject (optional)", "default": "Just landed in L.A.", - "direct_warning": "This post will only be visible to all the mentioned users.", + "direct_warning_to_all": "This post will be visible to all the mentioned users.", + "direct_warning_to_first_only": "This post will only be visible to the mentioned users at the beginning of the message.", "posting": "Posting", + "scope_notice": { + "public": "This post will be visible to everyone", + "private": "This post will be visible to your followers only", + "unlisted": "This post will not be visible in Public Timeline and The Whole Known Network" + }, "scope": { "direct": "Direct - Post to mentioned users only", "private": "Followers-only - Post to followers only", @@ -108,8 +145,34 @@ "password_confirmation_match": "should be the same as password" } }, + "selectable_list": { + "select_all": "Select all" + }, "settings": { "app_name": "App name", + "security": "Security", + "enter_current_password_to_confirm": "Enter your current password to confirm your identity", + "mfa": { + "otp" : "OTP", + "setup_otp" : "Setup OTP", + "wait_pre_setup_otp" : "presetting OTP", + "confirm_and_enable" : "Confirm & enable OTP", + "title": "Two-factor Authentication", + "generate_new_recovery_codes" : "Generate new recovery codes", + "warning_of_generate_new_codes" : "When you generate new recovery codes, your old codes won’t work anymore.", + "recovery_codes" : "Recovery codes.", + "waiting_a_recovery_codes": "Receiving backup codes...", + "recovery_codes_warning" : "Write the codes down or save them somewhere secure - otherwise you won't see them again. If you lose access to your 2FA app and recovery codes you'll be locked out of your account.", + "authentication_methods" : "Authentication methods", + "scan": { + "title": "Scan", + "desc": "Using your two-factor app, scan this QR code or enter text key:", + "secret_code": "Key" + }, + "verify": { + "desc": "To enable two-factor authentication, enter the code from your two-factor app:" + } + }, "attachmentRadius": "Attachments", "attachments": "Attachments", "autoload": "Enable automatic loading when scrolled to the bottom", @@ -118,6 +181,11 @@ "avatarRadius": "Avatars", "background": "Background", "bio": "Bio", + "block_export": "Block export", + "block_export_button": "Export your blocks to a csv file", + "block_import": "Block import", + "block_import_error": "Error importing blocks", + "blocks_imported": "Blocks imported! Processing them will take a while.", "blocks_tab": "Blocks", "btnRadius": "Buttons", "cBlue": "Blue (Reply, follow)", @@ -145,7 +213,6 @@ "filtering_explanation": "All statuses containing these words will be muted, one per line", "follow_export": "Follow export", "follow_export_button": "Export your follows to a csv file", - "follow_export_processing": "Processing, you'll soon be asked to download your file", "follow_import": "Follow import", "follow_import_error": "Error importing followers", "follows_imported": "Follows imported! Processing them will take a while.", @@ -161,6 +228,7 @@ "hide_post_stats": "Hide post statistics (e.g. the number of favorites)", "hide_user_stats": "Hide user statistics (e.g. the number of followers)", "hide_filtered_statuses": "Hide filtered statuses", + "import_blocks_from_a_csv_file": "Import blocks from a csv file", "import_followers_from_a_csv_file": "Import follows from a csv file", "import_theme": "Load preset", "inputRadius": "Input fields", @@ -211,10 +279,14 @@ "reply_visibility_all": "Show all replies", "reply_visibility_following": "Only show replies directed at me or users I'm following", "reply_visibility_self": "Only show replies directed at me", + "autohide_floating_post_button": "Automatically hide New Post button (mobile)", "saving_err": "Error saving settings", "saving_ok": "Settings saved", + "search_user_to_block": "Search whom you want to block", + "search_user_to_mute": "Search whom you want to mute", "security_tab": "Security", "scope_copy": "Copy scope when replying (DMs are always copied)", + "minimal_scopes_mode": "Minimize post scope selection options", "set_new_avatar": "Set new avatar", "set_new_profile_background": "Set new profile background", "set_new_profile_banner": "Set new profile banner", @@ -240,6 +312,13 @@ "true": "yes" }, "notifications": "Notifications", + "notification_setting": "Receive notifications from:", + "notification_setting_follows": "Users you follow", + "notification_setting_non_follows": "Users you do not follow", + "notification_setting_followers": "Users who follow you", + "notification_setting_non_followers": "Users who do not follow you", + "notification_mutes": "To stop receiving notifications from a specific user, use a mute.", + "notification_blocks": "Blocking a user stops all notifications as well as unsubscribes them.", "enable_web_push_notifications": "Enable web push notifications", "style": { "switcher": { @@ -369,6 +448,13 @@ "no_statuses": "No statuses" }, "status": { + "favorites": "Favorites", + "repeats": "Repeats", + "delete": "Delete status", + "pin": "Pin on profile", + "unpin": "Unpin from profile", + "pinned": "Pinned", + "delete_confirm": "Do you really want to delete this status?", "reply_to": "Reply to", "replies_list": "Replies:" }, @@ -393,19 +479,48 @@ "muted": "Muted", "per_day": "per day", "remote_follow": "Remote follow", + "report": "Report", "statuses": "Statuses", "unblock": "Unblock", "unblock_progress": "Unblocking...", "block_progress": "Blocking...", "unmute": "Unmute", "unmute_progress": "Unmuting...", - "mute_progress": "Muting..." + "mute_progress": "Muting...", + "admin_menu": { + "moderation": "Moderation", + "grant_admin": "Grant Admin", + "revoke_admin": "Revoke Admin", + "grant_moderator": "Grant Moderator", + "revoke_moderator": "Revoke Moderator", + "activate_account": "Activate account", + "deactivate_account": "Deactivate account", + "delete_account": "Delete account", + "force_nsfw": "Mark all posts as NSFW", + "strip_media": "Remove media from posts", + "force_unlisted": "Force posts to be unlisted", + "sandbox": "Force posts to be followers-only", + "disable_remote_subscription": "Disallow following user from remote instances", + "disable_any_subscription": "Disallow following user at all", + "quarantine": "Disallow user posts from federating", + "delete_user": "Delete user", + "delete_user_confirmation": "Are you absolutely sure? This action cannot be undone." + } }, "user_profile": { "timeline_title": "User Timeline", "profile_does_not_exist": "Sorry, this profile does not exist.", "profile_loading_error": "Sorry, there was an error loading this profile." }, + "user_reporting": { + "title": "Reporting {0}", + "add_comment_description": "The report will be sent to your instance moderators. You can provide an explanation of why you are reporting this account below:", + "additional_comments": "Additional comments", + "forward_description": "The account is from another server. Send a copy of the report there as well?", + "forward_to": "Forward to {0}", + "submit": "Submit", + "generic_error": "An error occurred while processing your request." + }, "who_to_follow": { "more": "More", "who_to_follow": "Who to follow" diff --git a/src/i18n/es.json b/src/i18n/es.json index a692eef9..2e38f859 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -2,6 +2,10 @@ "chat": { "title": "Chat" }, + "exporter": { + "export": "Exportar", + "processing": "Procesando. Pronto se te pedirá que descargues tu archivo" + }, "features_panel": { "chat": "Chat", "gopher": "Gopher", @@ -19,7 +23,22 @@ "apply": "Aplicar", "submit": "Enviar", "more": "Más", - "generic_error": "Ha ocurrido un error" + "generic_error": "Ha ocurrido un error", + "optional": "opcional", + "show_more": "Mostrar más", + "show_less": "Mostrar menos", + "cancel": "Cancelar" + }, + "image_cropper": { + "crop_picture": "Recortar la foto", + "save": "Guardar", + "save_without_cropping": "Guardar sin recortar", + "cancel": "Cancelar" + }, + "importer": { + "submit": "Enviar", + "success": "Importado con éxito", + "error": "Se ha producido un error al importar el archivo." }, "login": { "login": "Identificación", @@ -31,6 +50,10 @@ "username": "Usuario", "hint": "Inicia sesión para unirte a la discusión" }, + "media_modal": { + "previous": "Anterior", + "next": "Siguiente" + }, "nav": { "about": "Sobre", "back": "Volver", @@ -61,15 +84,19 @@ "account_not_locked_warning_link": "bloqueada", "attachments_sensitive": "Contenido sensible", "content_type": { - "text/plain": "Texto Plano" + "text/plain": "Texto Plano", + "text/html": "HTML", + "text/markdown": "Markdown", + "text/bbcode": "BBCode" }, "content_warning": "Tema (opcional)", "default": "Acabo de aterrizar en L.A.", - "direct_warning": "Esta entrada solo será visible para los usuarios mencionados.", + "direct_warning": "Esta publicación solo será visible para los usuarios mencionados.", + "direct_warning_to_first_only": "Esta publicación solo será visible para los usuarios mencionados al comienzo del mensaje.", "posting": "Publicando", "scope": { "direct": "Directo - Solo para los usuarios mencionados.", - "private": "Solo-Seguidores - Solo tus seguidores leeran la entrada", + "private": "Solo-Seguidores - Solo tus seguidores leeran la publicación", "public": "Público - Entradas visibles en las Líneas Temporales Públicas", "unlisted": "Sin Listar - Entradas no visibles en las Líneas Temporales Públicas" } @@ -83,6 +110,9 @@ "token": "Token de invitación", "captcha": "CAPTCHA", "new_captcha": "Click en la imagen para obtener un nuevo captca", + "username_placeholder": "p.ej. lain", + "fullname_placeholder": "p.ej. Lain Iwakura", + "bio_placeholder": "e.g.\nHola, soy un ejemplo.\nAquí puedes poner algo representativo tuyo... o no.", "validations": { "username_required": "no puede estar vacío", "fullname_required": "no puede estar vacío", @@ -92,7 +122,11 @@ "password_confirmation_match": "la contraseña no coincide" } }, + "selectable_list": { + "select_all": "Seleccionarlo todo" + }, "settings": { + "app_name": "Nombre de la aplicación", "attachmentRadius": "Adjuntos", "attachments": "Adjuntos", "autoload": "Activar carga automática al llegar al final de la página", @@ -101,6 +135,12 @@ "avatarRadius": "Avatares", "background": "Fondo", "bio": "Biografía", + "block_export": "Exportar usuarios bloqueados", + "block_export_button": "Exporta la lista de tus usarios bloqueados a un archivo csv", + "block_import": "Importar usuarios bloqueados", + "block_import_error": "Error importando la lista de usuarios bloqueados", + "blocks_imported": "¡Lista de usuarios bloqueados importada! El procesado puede tardar un poco.", + "blocks_tab": "Bloqueados", "btnRadius": "Botones", "cBlue": "Azul (Responder, seguir)", "cGreen": "Verde (Retweet)", @@ -127,7 +167,6 @@ "filtering_explanation": "Todos los estados que contengan estas palabras serán silenciados, una por línea", "follow_export": "Exportar personas que tú sigues", "follow_export_button": "Exporta tus seguidores a un archivo csv", - "follow_export_processing": "Procesando, en breve se te preguntará para guardar el archivo", "follow_import": "Importar personas que tú sigues", "follow_import_error": "Error al importal el archivo", "follows_imported": "¡Importado! Procesarlos llevará tiempo.", @@ -135,12 +174,15 @@ "general": "General", "hide_attachments_in_convo": "Ocultar adjuntos en las conversaciones", "hide_attachments_in_tl": "Ocultar adjuntos en la línea temporal", + "hide_muted_posts": "Ocultar las publicaciones de los usuarios silenciados", + "max_thumbnails": "Cantidad máxima de miniaturas por publicación", "hide_isp": "Ocultar el panel específico de la instancia", "preload_images": "Precargar las imágenes", "use_one_click_nsfw": "Abrir los adjuntos NSFW con un solo click.", "hide_post_stats": "Ocultar las estadísticas de las entradas (p.ej. el número de favoritos)", "hide_user_stats": "Ocultar las estadísticas del usuario (p.ej. el número de seguidores)", "hide_filtered_statuses": "Ocultar estados filtrados", + "import_blocks_from_a_csv_file": "Importar lista de usuarios bloqueados dese un archivo csv", "import_followers_from_a_csv_file": "Importar personas que tú sigues a partir de un archivo csv", "import_theme": "Importar tema", "inputRadius": "Campos de entrada", @@ -155,6 +197,7 @@ "lock_account_description": "Restringir el acceso a tu cuenta solo a seguidores admitidos", "loop_video": "Vídeos en bucle", "loop_video_silent_only": "Bucle solo en vídeos sin sonido (p.ej. \"gifs\" de Mastodon)", + "mutes_tab": "Silenciados", "play_videos_in_modal": "Reproducir los vídeos directamente en el visor de medios", "use_contain_fit": "No recortar los adjuntos en miniaturas", "name": "Nombre", @@ -166,6 +209,8 @@ "notification_visibility_mentions": "Menciones", "notification_visibility_repeats": "Repeticiones (Repeats)", "no_rich_text_description": "Eliminar el formato de texto enriquecido de todas las entradas", + "no_blocks": "No hay usuarios bloqueados", + "no_mutes": "No hay usuarios sinlenciados", "hide_follows_description": "No mostrar a quién sigo", "hide_followers_description": "No mostrar quién me sigue", "show_admin_badge": "Mostrar la placa de administrador en mi perfil", @@ -190,8 +235,11 @@ "reply_visibility_self": "Solo mostrar réplicas para mí", "saving_err": "Error al guardar los ajustes", "saving_ok": "Ajustes guardados", + "search_user_to_block": "Buscar usuarios a bloquear", + "search_user_to_mute": "Buscar usuarios a silenciar", "security_tab": "Seguridad", - "scope_copy": "Copiar la visibilidad cuando contestamos (En los mensajes directos (MDs) siempre se copia)", + "scope_copy": "Copiar la visibilidad de la publicación cuando contestamos (En los mensajes directos (MDs) siempre se copia)", + "minimal_scopes_mode": "Minimizar las opciones de publicación", "set_new_avatar": "Cambiar avatar", "set_new_profile_background": "Cambiar fondo del perfil", "set_new_profile_banner": "Cambiar cabecera del perfil", @@ -210,6 +258,7 @@ "theme_help_v2_1": "También puede invalidar los colores y la opacidad de ciertos componentes si activa la casilla de verificación, use el botón \"Borrar todo\" para deshacer los cambios.", "theme_help_v2_2": "Los iconos debajo de algunas entradas son indicadores de contraste de fondo/texto, desplace el ratón para obtener información detallada. Tenga en cuenta que cuando se utilizan indicadores de contraste de transparencia se muestra el peor caso posible.", "tooltipRadius": "Información/alertas", + "upload_a_photo": "Subir una foto", "user_settings": "Ajustes de Usuario", "values": { "false": "no", @@ -325,6 +374,11 @@ "checkbox": "He revisado los términos y condiciones", "link": "un bonito enlace" } + }, + "version": { + "title": "Versión", + "backend_version": "Versión del Backend", + "frontend_version": "Versión del Frontend" } }, "timeline": { @@ -336,7 +390,14 @@ "repeated": "repetida", "show_new": "Mostrar lo nuevo", "up_to_date": "Actualizado", - "no_more_statuses": "No hay más estados" + "no_more_statuses": "No hay más estados", + "no_statuses": "Sin estados" + }, + "status": { + "favorites": "Favoritos", + "repeats": "Repetidos", + "reply_to": "Responder a", + "replies_list": "Respuestas:" }, "user_card": { "approve": "Aprovar", @@ -359,10 +420,47 @@ "muted": "Silenciado", "per_day": "por día", "remote_follow": "Seguir", - "statuses": "Estados" + "report": "Reportar", + "statuses": "Estados", + "unblock": "Desbloquear", + "unblock_progress": "Desbloqueando...", + "block_progress": "Bloqueando...", + "unmute": "Desenmudecer", + "unmute_progress": "Sesenmudeciendo...", + "mute_progress": "Silenciando...", + "admin_menu": { + "moderation": "Moderación", + "grant_admin": "Conceder permisos de Administrador", + "revoke_admin": "Revocar permisos de Administrador", + "grant_moderator": "Conceder permisos de Moderador", + "revoke_moderator": "Revocar permisos de Moderador", + "activate_account": "Activar cuenta", + "deactivate_account": "Desactivar cuenta", + "delete_account": "Borrar cuenta", + "force_nsfw": "Marcar todas las publicaciones como NSFW (no es seguro/apropiado para el trabajo)", + "strip_media": "Eliminar archivos multimedia de las publicaciones", + "force_unlisted": "Forzar que se publique en el modo -Sin Listar-", + "sandbox": "Forzar que se publique solo para tus seguidores", + "disable_remote_subscription": "No permitir que usuarios de instancias remotas te siga.", + "disable_any_subscription": "No permitir que ningún usuario te siga", + "quarantine": "No permitir publicaciones de usuarios de instancias remotas", + "delete_user": "Borrar usuario", + "delete_user_confirmation": "¿Estás completamente seguro? Esta acción no se puede deshacer." + } }, "user_profile": { - "timeline_title": "Linea temporal del usuario" + "timeline_title": "Linea temporal del usuario", + "profile_does_not_exist": "Lo sentimos, este perfil no existe.", + "profile_loading_error": "Lo sentimos, hubo un error al cargar este perfil." + }, + "user_reporting": { + "title": "Reportando a {0}", + "add_comment_description": "El informe será enviado a los moderadores de su instancia. Puedes proporcionar una explicación de por qué estás reportando esta cuenta a continuación:", + "additional_comments": "Comentarios adicionales", + "forward_description": "La cuenta es de otro servidor. ¿Enviar una copia del informe allí también?", + "forward_to": "Reenviar a {0}", + "submit": "Enviar", + "generic_error": "Se produjo un error al procesar la solicitud." }, "who_to_follow": { "more": "Más", @@ -389,4 +487,4 @@ "TiB": "TiB" } } -} +}
\ No newline at end of file diff --git a/src/i18n/fi.json b/src/i18n/fi.json index fbe676cf..62cbecb8 100644 --- a/src/i18n/fi.json +++ b/src/i18n/fi.json @@ -222,6 +222,8 @@ "no_more_statuses": "Ei enempää viestejä" }, "status": { + "favorites": "Tykkäykset", + "repeats": "Toistot", "reply_to": "Vastaus", "replies_list": "Vastaukset:" }, diff --git a/src/i18n/he.json b/src/i18n/he.json index ea581e05..1c034960 100644 --- a/src/i18n/he.json +++ b/src/i18n/he.json @@ -2,6 +2,10 @@ "chat": { "title": "צ'אט" }, + "exporter": { + "export": "ייצוא", + "processing": "מעבד, בקרוב תופיע אפשרות להוריד את הקובץ" + }, "features_panel": { "chat": "צ'אט", "gopher": "גופר", @@ -17,23 +21,53 @@ }, "general": { "apply": "החל", - "submit": "שלח" + "submit": "שלח", + "more": "עוד", + "generic_error": "קרתה שגיאה", + "optional": "לבחירה", + "show_more": "הראה עוד", + "show_less": "הראה פחות", + "cancel": "בטל" + }, + "image_cropper": { + "crop_picture": "חתוך תמונה", + "save": "שמור", + "save_without_cropping": "שמור בלי לחתוך", + "cancel": "בטל" + }, + "importer": { + "submit": "שלח", + "success": "ייובא בהצלחה.", + "error": "אירעתה שגיאה בזמן ייבוא קובץ זה." }, "login": { "login": "התחבר", + "description": "היכנס עם OAuth", "logout": "התנתק", "password": "סיסמה", "placeholder": "למשל lain", "register": "הירשם", - "username": "שם המשתמש" + "username": "שם המשתמש", + "hint": "הירשם על מנת להצטרף לדיון" + }, + "media_modal": { + "previous": "הקודם", + "next": "הבא" }, "nav": { + "about": "על-אודות", + "back": "חזור", "chat": "צ'אט מקומי", "friend_requests": "בקשות עקיבה", "mentions": "אזכורים", + "interactions": "אינטרקציות", + "dms": "הודעות ישירות", "public_tl": "ציר הזמן הציבורי", "timeline": "ציר הזמן", - "twkn": "כל הרשת הידועה" + "twkn": "כל הרשת הידועה", + "user_search": "חיפוש משתמש", + "who_to_follow": "אחרי מי לעקוב", + "preferences": "העדפות" }, "notifications": { "broken_favorite": "סטאטוס לא ידוע, מחפש...", @@ -42,19 +76,35 @@ "load_older": "טען התראות ישנות", "notifications": "התראות", "read": "קרא!", - "repeated_you": "חזר על הסטטוס שלך" + "repeated_you": "חזר על הסטטוס שלך", + "no_more_notifications": "לא עוד התראות" + }, + "interactions": { + "favs_repeats": "חזרות ומועדפים", + "follows": "עוקבים חדשים", + "load_older": "טען אינטרקציות ישנות" }, "post_status": { + "new_status": "פרסם סטאטוס חדש", "account_not_locked_warning": "המשתמש שלך אינו {0}. כל אחד יכול לעקוב אחריך ולראות את ההודעות לעוקבים-בלבד שלך.", "account_not_locked_warning_link": "נעול", "attachments_sensitive": "סמן מסמכים מצורפים כלא בטוחים לצפייה", "content_type": { - "text/plain": "טקסט פשוט" + "text/plain": "טקסט פשוט", + "text/html": "HTML", + "text/markdown": "Markdown", + "text/bbcode": "BBCode" }, "content_warning": "נושא (נתון לבחירה)", "default": "הרגע נחת ב-ל.א.", - "direct_warning": "הודעה זו תהיה זמינה רק לאנשים המוזכרים.", + "direct_warning_to_all": "הודעה זו תהיה נראית לכל המשתמשים המוזכרים.", + "direct_warning_to_first_only": "הודעה זו תהיה נראית לכל המשתמשים במוזכרים בתחילת ההודעה בלבד.", "posting": "מפרסם", + "scope_notice": { + "public": "הודעה זו תהיה נראית לכולם", + "private": "הודעה זו תהיה נראית לעוקבים שלך בלבד", + "unlisted": "הודעה זו לא תהיה נראית בציר זמן הציבורי או בכל הרשת הידועה" + }, "scope": { "direct": "ישיר - שלח לאנשים המוזכרים בלבד", "private": "עוקבים-בלבד - שלח לעוקבים בלבד", @@ -68,9 +118,26 @@ "fullname": "שם תצוגה", "password_confirm": "אישור סיסמה", "registration": "הרשמה", - "token": "טוקן הזמנה" + "token": "טוקן הזמנה", + "captcha": "אימות אנוש", + "new_captcha": "לחץ על התמונה על מנת לקבל אימות אנוש חדש", + "username_placeholder": "למשל lain", + "fullname_placeholder": "למשל Lain Iwakura", + "bio_placeholder": "למשל\nהיי, אני ליין.\nאני ילדת אנימה שגרה בפרוורי יפן. אולי אתם מכירים אותי מהWired.", + "validations": { + "username_required": "לא יכול להישאר ריק", + "fullname_required": "לא יכול להישאר ריק", + "email_required": "לא יכול להישאר ריק", + "password_required": "לא יכול להישאר ריק", + "password_confirmation_required": "לא יכול להישאר ריק", + "password_confirmation_match": "צריך להיות דומה לסיסמה" + } + }, + "selectable_list": { + "select_all": "בחר הכל" }, "settings": { + "app_name": "שם האפליקציה", "attachmentRadius": "צירופים", "attachments": "צירופים", "autoload": "החל טעינה אוטומטית בגלילה לתחתית הדף", @@ -79,6 +146,12 @@ "avatarRadius": "תמונות פרופיל", "background": "רקע", "bio": "אודות", + "block_export": "ייצוא חסימות", + "block_export_button": "ייצוא חסימות אל קובץ csv", + "block_import": "ייבוא חסימות", + "block_import_error": "שגיאה בייבוא החסימות", + "blocks_imported": "החסימות יובאו! ייקח מעט זמן לעבד אותן.", + "blocks_tab": "חסימות", "btnRadius": "כפתורים", "cBlue": "כחול (תגובה, עקיבה)", "cGreen": "ירוק (חזרה)", @@ -88,6 +161,7 @@ "change_password_error": "הייתה בעיה בשינוי סיסמתך.", "changed_password": "סיסמה שונתה בהצלחה!", "collapse_subject": "מזער הודעות עם נושאים", + "composing": "מרכיב", "confirm_new_password": "אשר סיסמה", "current_avatar": "תמונת הפרופיל הנוכחית שלך", "current_password": "סיסמה נוכחית", @@ -98,21 +172,35 @@ "delete_account_description": "מחק לצמיתות את המשתמש שלך ואת כל הודעותיך.", "delete_account_error": "הייתה בעיה במחיקת המשתמש. אם זה ממשיך, אנא עדכן את מנהל השרת שלך.", "delete_account_instructions": "הכנס את סיסמתך בקלט למטה על מנת לאשר מחיקת משתמש.", + "avatar_size_instruction": "הגודל המינימלי המומלץ לתמונות פרופיל הוא 150x150 פיקסלים.", "export_theme": "שמור ערכים", "filtering": "סינון", "filtering_explanation": "כל הסטטוסים הכוללים את המילים הללו יושתקו, אחד לשורה", "follow_export": "יצוא עקיבות", "follow_export_button": "ייצא את הנעקבים שלך לקובץ csv", - "follow_export_processing": "טוען. בקרוב תתבקש להוריד את הקובץ את הקובץ שלך", "follow_import": "יבוא עקיבות", "follow_import_error": "שגיאה בייבוא נעקבים.", "follows_imported": "נעקבים יובאו! ייקח זמן מה לעבד אותם.", "foreground": "חזית", + "general": "כללי", "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": "הסתר נתוני משתמש (למשל, מספר העוקבים)", + "hide_filtered_statuses": "מסתר סטטוסים מסוננים", + "import_blocks_from_a_csv_file": "ייבא חסימות מקובץ csv", "import_followers_from_a_csv_file": "ייבא את הנעקבים שלך מקובץ csv", "import_theme": "טען ערכים", "inputRadius": "שדות קלט", + "checkboxRadius": "תיבות סימון", + "instance_default": "(default: {value})", + "instance_default_simple": "(default)", + "interface": "ממשק", "interfaceLanguage": "שפת הממשק", "invalid_theme_imported": "הקובץ הנבחר אינו תמה הנתמכת ע\"י פלרומה. שום שינויים לא נעשו לתמה שלך.", "limited_availability": "לא זמין בדפדפן שלך", @@ -120,6 +208,9 @@ "lock_account_description": "הגבל את המשתמש לעוקבים מאושרים בלבד", "loop_video": "נגן סרטונים ללא הפסקה", "loop_video_silent_only": "נגן רק סרטונים חסרי קול ללא הפסקה", + "mutes_tab": "השתקות", + "play_videos_in_modal": "נגן סרטונים ישירות בנגן המדיה", + "use_contain_fit": "אל תחתוך את הצירוף בתמונות הממוזערות", "name": "שם", "name_bio": "שם ואודות", "new_password": "סיסמה חדשה", @@ -128,6 +219,13 @@ "notification_visibility_likes": "לייקים", "notification_visibility_mentions": "אזכורים", "notification_visibility_repeats": "חזרות", + "no_rich_text_description": "הסר פורמט טקסט עשיר מכל ההודעות", + "no_blocks": "ללא חסימות", + "no_mutes": "ללא השתקות", + "hide_follows_description": "אל תראה אחרי מי אני עוקב", + "hide_followers_description": "אל תראה מי עוקב אחרי", + "show_admin_badge": "הראה סמל מנהל בפרופיל שלי", + "show_moderator_badge": "הראה סמל צוות בפרופיל שלי", "nsfw_clickthrough": "החל החבאת צירופים לא בטוחים לצפיה בעת עבודה בעזרת לחיצת עכבר", "oauth_tokens": "אסימוני OAuth", "token": "אסימון", @@ -146,18 +244,43 @@ "reply_visibility_all": "הראה את כל התגובות", "reply_visibility_following": "הראה תגובות שמופנות אליי או לעקובים שלי בלבד", "reply_visibility_self": "הראה תגובות שמופנות אליי בלבד", + "autohide_floating_post_button": "החבא אוטומטית את הכפתור הודעה חדשה (נייד)", + "saving_err": "שגיאה בשמירת הגדרות", + "saving_ok": "הגדרות נשמרו", + "search_user_to_block": "חפש משתמש לחסימה", + "search_user_to_mute": "חפש משתמש להשתקה", "security_tab": "ביטחון", + "scope_copy": "העתק תחום הודעה בתגובה להודעה (הודעות ישירות תמיד מועתקות)", + "minimal_scopes_mode": "צמצם אפשרויות בחירה לתחום הודעה", "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_mastodon": "כמו מסטודון: העתק כפי שזה", + "subject_line_noop": "אל תעתיק", + "post_status_content_type": "שלח את סוג תוכן ההודעה", "stop_gifs": "נגן-בעת-ריחוף GIFs", "streaming": "החל זרימת הודעות אוטומטית בעת גלילה למעלה הדף", "text": "טקסט", "theme": "תמה", "theme_help": "השתמש בקודי צבע הקס (#אדום-אדום-ירוק-ירוק-כחול-כחול) על מנת להתאים אישית את תמת הצבע שלך.", "tooltipRadius": "טולטיפ \\ התראות", - "user_settings": "הגדרות משתמש" + "upload_a_photo": "העלה תמונה", + "user_settings": "הגדרות משתמש", + "values": { + "false": "לא", + "true": "כן" + }, + "notifications": "התראות", + "enable_web_push_notifications": "אפשר התראות web push", + "version": { + "title": "גרסה", + "backend_version": "גרסת קצה אחורי", + "frontend_version": "גרסת קצה קדמי" + } }, "timeline": { "collapse": "מוטט", @@ -167,29 +290,107 @@ "no_retweet_hint": "ההודעה מסומנת כ\"לעוקבים-בלבד\" ולא ניתן לחזור עליה", "repeated": "חזר", "show_new": "הראה חדש", - "up_to_date": "עדכני" + "up_to_date": "עדכני", + "no_more_statuses": "אין עוד סטטוסים", + "no_statuses": "אין סטטוסים" + }, + "status": { + "favorites": "מועדפים", + "repeats": "חזרות", + "delete": "מחק סטטוס", + "pin": "הצמד לפרופיל", + "unpin": "הסר הצמדה מהפרופיל", + "pinned": "מוצמד", + "delete_confirm": "האם באמת למחוק סטטוס זה?", + "reply_to": "הגב ל", + "replies_list": "תגובות:" }, "user_card": { "approve": "אשר", "block": "חסימה", "blocked": "חסום!", "deny": "דחה", + "favorites": "מועדפים", "follow": "עקוב", + "follow_sent": "בקשה נשלחה!", + "follow_progress": "מבקש...", + "follow_again": "שלח בקשה שוב?", + "follow_unfollow": "בטל עקיבה", "followees": "נעקבים", "followers": "עוקבים", "following": "עוקב!", "follows_you": "עוקב אחריך!", + "its_you": "זה אתה!", + "media": "מדיה", "mute": "השתק", "muted": "מושתק", "per_day": "ליום", "remote_follow": "עקיבה מרחוק", - "statuses": "סטטוסים" + "report": "דווח", + "statuses": "סטטוסים", + "unblock": "הסר חסימה", + "unblock_progress": "מסיר חסימה...", + "block_progress": "חוסם...", + "unmute": "הסר השתקה", + "unmute_progress": "מסיר השתקה...", + "mute_progress": "משתיק...", + "admin_menu": { + "moderation": "ניהול (צוות)", + "grant_admin": "הפוך למנהל", + "revoke_admin": "הסר מנהל", + "grant_moderator": "הפוך לצוות", + "revoke_moderator": "הסר צוות", + "activate_account": "הפעל משתמש", + "deactivate_account": "השבת משתמש", + "delete_account": "מחק משתמש", + "force_nsfw": "סמן את כל ההודעות בתור לא-מתאימות-לעבודה", + "strip_media": "הסר מדיה מההודעות", + "force_unlisted": "הפוך הודעות ללא רשומות", + "sandbox": "הפוך הודעות לנראות לעוקבים-בלבד", + "disable_remote_subscription": "אל תאפשר עקיבה של המשתמש מאינסטנס אחר", + "disable_any_subscription": "אל תאפשר עקיבה של המשתמש בכלל", + "quarantine": "אל תאפשר פדרציה של ההודעות של המשתמש", + "delete_user": "מחק משתמש", + "delete_user_confirmation": "בטוח? פעולה זו הינה בלתי הפיכה." + } }, "user_profile": { - "timeline_title": "ציר זמן המשתמש" + "timeline_title": "ציר זמן המשתמש", + "profile_does_not_exist": "סליחה, פרופיל זה אינו קיים.", + "profile_loading_error": "סליחה, הייתה שגיאה בטעינת הפרופיל." + }, + "user_reporting": { + "title": "מדווח על {0}", + "add_comment_description": "הדיווח ישלח לצוות האינסטנס. אפשר להסביר למה הנך מדווחים על משתמש זה למטה:", + "additional_comments": "תגובות נוספות", + "forward_description": "המשתמש משרת אחר. לשלוח לשם עותק של הדיווח?", + "forward_to": "העבר ל {0}", + "submit": "הגש", + "generic_error": "קרתה שגיאה בעת עיבוד הבקשה." }, "who_to_follow": { "more": "עוד", "who_to_follow": "אחרי מי לעקוב" + }, + "tool_tip": { + "media_upload": "העלה מדיה", + "repeat": "חזור", + "reply": "הגב", + "favorite": "מועדף", + "user_settings": "הגדרות משתמש" + }, + "upload":{ + "error": { + "base": "העלאה נכשלה.", + "file_too_big": "קובץ גדול מדי [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]", + "default": "נסה שוב אחר כך" + }, + "file_size_units": { + "B": "B", + "KiB": "KiB", + "MiB": "MiB", + "GiB": "GiB", + "TiB": "TiB" + } } } diff --git a/src/i18n/ja.json b/src/i18n/ja.json index b77f5531..87ab9dfd 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -2,6 +2,10 @@ "chat": { "title": "チャット" }, + "exporter": { + "export": "エクスポート", + "processing": "おまちください。しばらくすると、あなたのファイルをダウンロードするように、メッセージがでます。" + }, "features_panel": { "chat": "チャット", "gopher": "Gopher", @@ -19,7 +23,22 @@ "apply": "てきよう", "submit": "そうしん", "more": "つづき", - "generic_error": "エラーになりました" + "generic_error": "エラーになりました", + "optional": "かかなくてもよい", + "show_more": "つづきをみる", + "show_less": "たたむ", + "cancel": "キャンセル" + }, + "image_cropper": { + "crop_picture": "がぞうをきりぬく", + "save": "セーブ", + "save_without_cropping": "きりぬかずにセーブ", + "cancel": "キャンセル" + }, + "importer": { + "submit": "そうしん", + "success": "インポートできました。", + "error": "インポートがエラーになりました。" }, "login": { "login": "ログイン", @@ -31,12 +50,17 @@ "username": "ユーザーめい", "hint": "はなしあいにくわわるには、ログインしてください" }, + "media_modal": { + "previous": "まえ", + "next": "つぎ" + }, "nav": { "about": "これはなに?", "back": "もどる", "chat": "ローカルチャット", "friend_requests": "フォローリクエスト", "mentions": "メンション", + "interactions": "やりとり", "dms": "ダイレクトメッセージ", "public_tl": "パブリックタイムライン", "timeline": "タイムライン", @@ -55,18 +79,33 @@ "repeated_you": "あなたのステータスがリピートされました", "no_more_notifications": "つうちはありません" }, + "interactions": { + "favs_repeats": "リピートとおきにいり", + "follows": "あたらしいフォロー", + "load_older": "ふるいやりとりをみる" + }, "post_status": { "new_status": "とうこうする", "account_not_locked_warning": "あなたのアカウントは {0} ではありません。あなたをフォローすれば、だれでも、フォロワーげんていのステータスをよむことができます。", "account_not_locked_warning_link": "ロックされたアカウント", "attachments_sensitive": "ファイルをNSFWにする", "content_type": { - "text/plain": "プレーンテキスト" + "text/plain": "プレーンテキスト", + "text/html": "HTML", + "text/markdown": "Markdown", + "text/bbcode": "BBCode" }, "content_warning": "せつめい (かかなくてもよい)", "default": "はねだくうこうに、つきました。", + "direct_warning_to_all": "このとうこうは、メンションされたすべてのユーザーが、みることができます。", + "direct_warning_to_first_only": "このとうこうは、メッセージのはじめでメンションされたユーザーだけが、みることができます。", "direct_warning": "このステータスは、メンションされたユーザーだけが、よむことができます。", "posting": "とうこう", + "scope_notice": { + "public": "このとうこうは、だれでもみることができます", + "private": "このとうこうは、あなたのフォロワーだけが、みることができます", + "unlisted": "このとうこうは、パブリックタイムラインと、つながっているすべてのネットワークでは、みることができません" + }, "scope": { "direct": "ダイレクト: メンションされたユーザーのみにとどきます。", "private": "フォロワーげんてい: フォロワーのみにとどきます。", @@ -83,6 +122,9 @@ "token": "しょうたいトークン", "captcha": "CAPTCHA", "new_captcha": "もじがよめないときは、がぞうをクリックすると、あたらしいがぞうになります", + "username_placeholder": "れい: lain", + "fullname_placeholder": "れい: いわくら れいん", + "bio_placeholder": "れい:\nごきげんよう。わたしはれいん。\nわたしはアニメのおんなのこで、にほんのベッドタウンにすんでいます。ワイヤードで、わたしにあったことが、あるかもしれませんね。", "validations": { "username_required": "なにかかいてください", "fullname_required": "なにかかいてください", @@ -92,7 +134,11 @@ "password_confirmation_match": "パスワードがちがいます" } }, + "selectable_list": { + "select_all": "すべてえらぶ" + }, "settings": { + "app_name": "アプリのなまえ", "attachmentRadius": "ファイル", "attachments": "ファイル", "autoload": "したにスクロールしたとき、じどうてきによみこむ。", @@ -101,6 +147,12 @@ "avatarRadius": "アバター", "background": "バックグラウンド", "bio": "プロフィール", + "block_export": "ブロックのエクスポート", + "block_export_button": "ブロックをCSVファイルにエクスポート", + "block_import": "ブロックのインポート", + "block_import_error": "ブロックのインポートがエラーになりました", + "blocks_imported": "ブロックをインポートしました! じっさいにブロックするまでには、もうしばらくかかります。", + "blocks_tab": "ブロック", "btnRadius": "ボタン", "cBlue": "リプライとフォロー", "cGreen": "リピート", @@ -135,12 +187,15 @@ "general": "ぜんぱん", "hide_attachments_in_convo": "スレッドのファイルをかくす", "hide_attachments_in_tl": "タイムラインのファイルをかくす", + "hide_muted_posts": "ミュートしたユーザーのとうこうをかくす", + "max_thumbnails": "ひとつのとうこうにいれられるサムネイルのかず", "hide_isp": "インスタンススペシフィックパネルをかくす", "preload_images": "がぞうをさきよみする", "use_one_click_nsfw": "NSFWなファイルを1クリックでひらく", "hide_post_stats": "とうこうのとうけいをかくす (れい: おきにいりのかず)", "hide_user_stats": "ユーザーのとうけいをかくす (れい: フォロワーのかず)", "hide_filtered_statuses": "フィルターされたとうこうをかくす", + "import_blocks_from_a_csv_file": "CSVファイルからブロックをインポートする", "import_followers_from_a_csv_file": "CSVファイルからフォローをインポートする", "import_theme": "ロード", "inputRadius": "インプットフィールド", @@ -155,6 +210,7 @@ "lock_account_description": "あなたがみとめたひとだけ、あなたのアカウントをフォローできる", "loop_video": "ビデオをくりかえす", "loop_video_silent_only": "おとのないビデオだけくりかえす", + "mutes_tab": "ミュート", "play_videos_in_modal": "ビデオをメディアビューアーでみる", "use_contain_fit": "がぞうのサムネイルを、きりぬかない", "name": "なまえ", @@ -166,16 +222,18 @@ "notification_visibility_mentions": "メンション", "notification_visibility_repeats": "リピート", "no_rich_text_description": "リッチテキストをつかわない", + "no_blocks": "ブロックしていません", + "no_mutes": "ミュートしていません", "hide_follows_description": "フォローしているひとをみせない", "hide_followers_description": "フォロワーをみせない", - "show_admin_badge": "アドミンのしるしをみる", - "show_moderator_badge": "モデレーターのしるしをみる", + "show_admin_badge": "アドミンのしるしをみせる", + "show_moderator_badge": "モデレーターのしるしをみせる", "nsfw_clickthrough": "NSFWなファイルをかくす", "oauth_tokens": "OAuthトークン", "token": "トークン", - "refresh_token": "トークンを更新", - "valid_until": "まで有効", - "revoke_token": "取り消す", + "refresh_token": "トークンをリフレッシュ", + "valid_until": "おわりのとき", + "revoke_token": "とりけす", "panelRadius": "パネル", "pause_on_unfocused": "タブにフォーカスがないときストリーミングをとめる", "presets": "プリセット", @@ -188,10 +246,14 @@ "reply_visibility_all": "すべてのリプライをみる", "reply_visibility_following": "わたしにあてられたリプライと、フォローしているひとからのリプライをみる", "reply_visibility_self": "わたしにあてられたリプライをみる", + "autohide_floating_post_button": "あたらしいとうこうのボタンを、じどうてきにかくす (モバイル)", "saving_err": "せっていをセーブできませんでした", "saving_ok": "せっていをセーブしました", + "search_user_to_block": "ブロックしたいひとを、ここでけんさくできます", + "search_user_to_mute": "ミュートしたいひとを、ここでけんさくできます", "security_tab": "セキュリティ", "scope_copy": "リプライするとき、こうかいはんいをコピーする (DMのこうかいはんいは、つねにコピーされます)", + "minimal_scopes_mode": "こうかいはんいせんたくオプションを、ちいさくする", "set_new_avatar": "あたらしいアバターをせっていする", "set_new_profile_background": "あたらしいプロフィールのバックグラウンドをせっていする", "set_new_profile_banner": "あたらしいプロフィールバナーを設定する", @@ -209,6 +271,7 @@ "theme_help": "カラーテーマをカスタマイズできます", "theme_help_v2_1": "チェックボックスをONにすると、コンポーネントごとに、いろと、とうめいどを、オーバーライドできます。「すべてクリア」ボタンをおすと、すべてのオーバーライドを、やめます。", "theme_help_v2_2": "バックグラウンドとテキストのコントラストをあらわすアイコンがあります。マウスをホバーすると、くわしいせつめいがでます。とうめいないろをつかっているときは、もっともわるいばあいのコントラストがしめされます。", + "upload_a_photo": "がぞうをアップロード", "tooltipRadius": "ツールチップとアラート", "user_settings": "ユーザーせってい", "values": { @@ -216,6 +279,13 @@ "true": "はい" }, "notifications": "つうち", + "notification_setting": "つうちをうけとる:", + "notification_setting_follows": "あなたがフォローしているひとから", + "notification_setting_non_follows": "あなたがフォローしていないひとから", + "notification_setting_followers": "あなたをフォローしているひとから", + "notification_setting_non_followers": "あなたをフォローしていないひとから", + "notification_mutes": "あるユーザーからのつうちをとめるには、ミュートしてください。", + "notification_blocks": "ブロックしているユーザーからのつうちは、すべてとまります。", "enable_web_push_notifications": "ウェブプッシュつうちをゆるす", "style": { "switcher": { @@ -325,6 +395,11 @@ "checkbox": "りようきやくを、よみました", "link": "ハイパーリンク" } + }, + "version": { + "title": "バージョン", + "backend_version": "バックエンドのバージョン", + "frontend_version": "フロントエンドのバージョン" } }, "timeline": { @@ -336,7 +411,19 @@ "repeated": "リピート", "show_new": "よみこみ", "up_to_date": "さいしん", - "no_more_statuses": "これでおわりです" + "no_more_statuses": "これでおわりです", + "no_statuses": "ありません" + }, + "status": { + "favorites": "おきにいり", + "repeats": "リピート", + "delete": "ステータスをけす", + "pin": "プロフィールにピンどめする", + "unpin": "プロフィールにピンどめするのをやめる", + "pinned": "ピンどめ", + "delete_confirm": "ほんとうに、このステータスを、けしてもいいですか?", + "reply_to": "へんしん:", + "replies_list": "へんしん:" }, "user_card": { "approve": "うけいれ", @@ -359,10 +446,47 @@ "muted": "ミュートしています!", "per_day": "/日", "remote_follow": "リモートフォロー", - "statuses": "ステータス" + "report": "つうほう", + "statuses": "ステータス", + "unblock": "ブロックをやめる", + "unblock_progress": "ブロックをとりけしています...", + "block_progress": "ブロックしています...", + "unmute": "ミュートをやめる", + "unmute_progress": "ミュートをとりけしています...", + "mute_progress": "ミュートしています...", + "admin_menu": { + "moderation": "モデレーション", + "grant_admin": "アドミンにする", + "revoke_admin": "アドミンをやめさせる", + "grant_moderator": "モデレーターにする", + "revoke_moderator": "モデレーターをやめさせる", + "activate_account": "アカウントをアクティブにする", + "deactivate_account": "アカウントをアクティブでなくする", + "delete_account": "アカウントをけす", + "force_nsfw": "すべてのとうこうをNSFWにする", + "strip_media": "とうこうからメディアをなくす", + "force_unlisted": "とうこうをアンリステッドにする", + "sandbox": "とうこうをフォロワーのみにする", + "disable_remote_subscription": "ほかのインスタンスからフォローされないようにする", + "disable_any_subscription": "フォローされないようにする", + "quarantine": "ほかのインスタンスのユーザーのとうこうをとめる", + "delete_user": "ユーザーをけす", + "delete_user_confirmation": "あなたは、ほんとうに、きはたしかですか? これは、とりけすことが、できません。" + } }, "user_profile": { - "timeline_title": "ユーザータイムライン" + "timeline_title": "ユーザータイムライン", + "profile_does_not_exist": "ごめんなさい。このプロフィールは、そんざいしません。", + "profile_loading_error": "ごめんなさい。プロフィールのロードがエラーになりました。" + }, + "user_reporting": { + "title": "つうほうする: {0}", + "add_comment_description": "このつうほうは、あなたのインスタンスのモデレーターに、おくられます。このアカウントを、つうほうするりゆうを、せつめいすることができます:", + "additional_comments": "ついかのコメント", + "forward_description": "このアカウントは、ほかのインスタンスのものです。そのインスタンスにも、このつうほうのコピーを、おくりますか?", + "forward_to": "コピーをおくる: {0}", + "submit": "そうしん", + "generic_error": "あなたのリクエストをうけつけようとしましたが、エラーになってしまいました。" }, "who_to_follow": { "more": "くわしく", diff --git a/src/i18n/ja_pedantic.json b/src/i18n/ja_pedantic.json new file mode 100644 index 00000000..9036baf5 --- /dev/null +++ b/src/i18n/ja_pedantic.json @@ -0,0 +1,516 @@ +{ + "chat": { + "title": "チャット" + }, + "exporter": { + "export": "エクスポート", + "processing": "処理中です。処理が完了すると、ファイルをダウンロードするよう指示があります。" + }, + "features_panel": { + "chat": "チャット", + "gopher": "Gopher", + "media_proxy": "メディアプロクシ", + "scope_options": "公開範囲選択", + "text_limit": "文字の数", + "title": "有効な機能", + "who_to_follow": "おすすめユーザー" + }, + "finder": { + "error_fetching_user": "ユーザー検索がエラーになりました。", + "find_user": "ユーザーを探す" + }, + "general": { + "apply": "適用", + "submit": "送信", + "more": "続き", + "generic_error": "エラーになりました", + "optional": "省略可", + "show_more": "もっと見る", + "show_less": "たたむ", + "cancel": "キャンセル" + }, + "image_cropper": { + "crop_picture": "画像を切り抜く", + "save": "保存", + "save_without_cropping": "切り抜かずに保存", + "cancel": "キャンセル" + }, + "importer": { + "submit": "送信", + "success": "正常にインポートされました。", + "error": "このファイルをインポートするとき、エラーが発生しました。" + }, + "login": { + "login": "ログイン", + "description": "OAuthでログイン", + "logout": "ログアウト", + "password": "パスワード", + "placeholder": "例: lain", + "register": "登録", + "username": "ユーザー名", + "hint": "会話に加わるには、ログインしてください" + }, + "media_modal": { + "previous": "前", + "next": "次" + }, + "nav": { + "about": "このインスタンスについて", + "back": "戻る", + "chat": "ローカルチャット", + "friend_requests": "フォローリクエスト", + "mentions": "通知", + "interactions": "インタラクション", + "dms": "ダイレクトメッセージ", + "public_tl": "パブリックタイムライン", + "timeline": "タイムライン", + "twkn": "接続しているすべてのネットワーク", + "user_search": "ユーザーを探す", + "who_to_follow": "おすすめユーザー", + "preferences": "設定" + }, + "notifications": { + "broken_favorite": "ステータスが見つかりません。探しています...", + "favorited_you": "あなたのステータスがお気に入りされました", + "followed_you": "フォローされました", + "load_older": "古い通知をみる", + "notifications": "通知", + "read": "読んだ!", + "repeated_you": "あなたのステータスがリピートされました", + "no_more_notifications": "通知はありません" + }, + "interactions": { + "favs_repeats": "リピートとお気に入り", + "follows": "新しいフォロワー", + "load_older": "古いインタラクションを見る" + }, + "post_status": { + "new_status": "投稿する", + "account_not_locked_warning": "あなたのアカウントは {0} ではありません。あなたをフォローすれば、誰でも、フォロワー限定のステータスを読むことができます。", + "account_not_locked_warning_link": "ロックされたアカウント", + "attachments_sensitive": "ファイルをNSFWにする", + "content_type": { + "text/plain": "プレーンテキスト", + "text/html": "HTML", + "text/markdown": "Markdown", + "text/bbcode": "BBCode" + }, + "content_warning": "説明 (省略可)", + "default": "羽田空港に着きました。", + "direct_warning_to_all": "この投稿は、メンションされたすべてのユーザーが、見ることができます。", + "direct_warning_to_first_only": "この投稿は、メッセージの冒頭でメンションされたユーザーだけが、見ることができます。", + "direct_warning": "このステータスは、メンションされたユーザーだけが、読むことができます。", + "posting": "投稿", + "scope_notice": { + "public": "この投稿は、誰でも見ることができます", + "private": "この投稿は、あなたのフォロワーだけが、見ることができます。", + "unlisted": "この投稿は、パブリックタイムラインと、接続しているすべてのネットワークには、表示されません。" + }, + "scope": { + "direct": "ダイレクト: メンションされたユーザーのみに届きます。", + "private": "フォロワーげんてい: フォロワーのみに届きます。", + "public": "パブリック: パブリックタイムラインに届きます。", + "unlisted": "アンリステッド: パブリックタイムラインに届きません。" + } + }, + "registration": { + "bio": "プロフィール", + "email": "Eメール", + "fullname": "スクリーンネーム", + "password_confirm": "パスワードの確認", + "registration": "登録", + "token": "招待トークン", + "captcha": "CAPTCHA", + "new_captcha": "文字が読めないときは、画像をクリックすると、新しい画像になります", + "username_placeholder": "例: lain", + "fullname_placeholder": "例: 岩倉玲音", + "bio_placeholder": "例:\nこんにちは。私は玲音。\n私はアニメのキャラクターで、日本の郊外に住んでいます。私をWiredで見たことがあるかもしれません。", + "validations": { + "username_required": "必須", + "fullname_required": "必須", + "email_required": "必須", + "password_required": "必須", + "password_confirmation_required": "必須", + "password_confirmation_match": "パスワードが違います" + } + }, + "selectable_list": { + "select_all": "すべて選択" + }, + "settings": { + "app_name": "アプリの名称", + "attachmentRadius": "ファイル", + "attachments": "ファイル", + "autoload": "下にスクロールしたとき、自動的に読み込む。", + "avatar": "アバター", + "avatarAltRadius": "通知のアバター", + "avatarRadius": "アバター", + "background": "バックグラウンド", + "bio": "プロフィール", + "block_export": "ブロックのエクスポート", + "block_export_button": "ブロックをCSVファイルにエクスポートする", + "block_import": "ブロックのインポート", + "block_import_error": "ブロックのインポートに失敗しました", + "blocks_imported": "ブロックをインポートしました! 実際に処理されるまでに、しばらく時間がかかります。", + "blocks_tab": "ブロック", + "btnRadius": "ボタン", + "cBlue": "返信とフォロー", + "cGreen": "リピート", + "cOrange": "お気に入り", + "cRed": "キャンセル", + "change_password": "パスワードを変える", + "change_password_error": "パスワードを変えることが、できなかったかもしれません。", + "changed_password": "パスワードが、変わりました!", + "collapse_subject": "説明のある投稿をたたむ", + "composing": "投稿", + "confirm_new_password": "新しいパスワードの確認", + "current_avatar": "現在のアバター", + "current_password": "現在のパスワード", + "current_profile_banner": "現在のプロフィールバナー", + "data_import_export_tab": "インポートとエクスポート", + "default_vis": "デフォルトの公開範囲", + "delete_account": "アカウントを消す", + "delete_account_description": "あなたのアカウントとメッセージが、消えます。", + "delete_account_error": "アカウントを消すことが、できなかったかもしれません。インスタンスの管理者に、連絡してください。", + "delete_account_instructions": "本当にアカウントを消してもいいなら、パスワードを入力してください。", + "avatar_size_instruction": "アバターの大きさは、150×150ピクセルか、それよりも大きくするといいです。", + "export_theme": "保存", + "filtering": "フィルタリング", + "filtering_explanation": "これらの言葉を含むすべてのものがミュートされます。1行に1つの言葉を書いてください。", + "follow_export": "フォローのエクスポート", + "follow_export_button": "エクスポート", + "follow_export_processing": "お待ちください。まもなくファイルをダウンロードできます。", + "follow_import": "フォローのインポート", + "follow_import_error": "フォローのインポートがエラーになりました。", + "follows_imported": "フォローがインポートされました! 少し時間がかかるかもしれません。", + "foreground": "フォアグラウンド", + "general": "全般", + "hide_attachments_in_convo": "スレッドのファイルを隠す", + "hide_attachments_in_tl": "タイムラインのファイルを隠す", + "hide_muted_posts": "ミュートしているユーザーの投稿を隠す", + "max_thumbnails": "投稿に含まれるサムネイルの最大数", + "hide_isp": "インスタンス固有パネルを隠す", + "preload_images": "画像を先読みする", + "use_one_click_nsfw": "NSFWなファイルを1クリックで開く", + "hide_post_stats": "投稿の統計を隠す (例: お気に入りの数)", + "hide_user_stats": "ユーザーの統計を隠す (例: フォロワーの数)", + "hide_filtered_statuses": "フィルターされた投稿を隠す", + "import_blocks_from_a_csv_file": "CSVファイルからブロックをインポートする", + "import_followers_from_a_csv_file": "CSVファイルからフォローをインポートする", + "import_theme": "ロード", + "inputRadius": "インプットフィールド", + "checkboxRadius": "チェックボックス", + "instance_default": "(デフォルト: {value})", + "instance_default_simple": "(デフォルト)", + "interface": "インターフェース", + "interfaceLanguage": "インターフェースの言語", + "invalid_theme_imported": "このファイルはPleromaのテーマではありません。テーマは変更されませんでした。", + "limited_availability": "あなたのブラウザではできません", + "links": "リンク", + "lock_account_description": "あなたが認めた人だけ、あなたのアカウントをフォローできる", + "loop_video": "ビデオを繰り返す", + "loop_video_silent_only": "音のないビデオだけ繰り返す", + "mutes_tab": "ミュート", + "play_videos_in_modal": "ビデオをメディアビューアーで見る", + "use_contain_fit": "画像のサムネイルを、切り抜かない", + "name": "名前", + "name_bio": "名前とプロフィール", + "new_password": "新しいパスワード", + "notification_visibility": "表示する通知", + "notification_visibility_follows": "フォロー", + "notification_visibility_likes": "お気に入り", + "notification_visibility_mentions": "メンション", + "notification_visibility_repeats": "リピート", + "no_rich_text_description": "リッチテキストを使わない", + "no_blocks": "ブロックはありません", + "no_mutes": "ミュートはありません", + "hide_follows_description": "フォローしている人を見せない", + "hide_followers_description": "フォロワーを見せない", + "show_admin_badge": "管理者のバッジを見せる", + "show_moderator_badge": "モデレーターのバッジを見せる", + "nsfw_clickthrough": "NSFWなファイルを隠す", + "oauth_tokens": "OAuthトークン", + "token": "トークン", + "refresh_token": "トークンを更新", + "valid_until": "まで有効", + "revoke_token": "取り消す", + "panelRadius": "パネル", + "pause_on_unfocused": "タブにフォーカスがないときストリーミングを止める", + "presets": "プリセット", + "profile_background": "プロフィールのバックグラウンド", + "profile_banner": "プロフィールバナー", + "profile_tab": "プロフィール", + "radii_help": "インターフェースの丸さを設定する。", + "replies_in_timeline": "タイムラインのリプライ", + "reply_link_preview": "カーソルを重ねたとき、リプライのプレビューを見る", + "reply_visibility_all": "すべてのリプライを見る", + "reply_visibility_following": "私に宛てられたリプライと、フォローしている人からのリプライを見る", + "reply_visibility_self": "私に宛てられたリプライを見る", + "autohide_floating_post_button": "新しい投稿ボタンを自動的に隠す (モバイル)", + "saving_err": "設定を保存できませんでした", + "saving_ok": "設定を保存しました", + "search_user_to_block": "ブロックしたいユーザーを検索", + "search_user_to_mute": "ミュートしたいユーザーを検索", + "security_tab": "セキュリティ", + "scope_copy": "返信するとき、公開範囲をコピーする (DMの公開範囲は、常にコピーされます)", + "minimal_scopes_mode": "公開範囲選択オプションを最小にする", + "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_mastodon": "マストドン風: そのままコピー", + "subject_line_noop": "コピーしない", + "post_status_content_type": "投稿のコンテントタイプ", + "stop_gifs": "カーソルを重ねたとき、GIFを動かす", + "streaming": "上までスクロールしたとき、自動的にストリーミングする", + "text": "文字", + "theme": "テーマ", + "theme_help": "カラーテーマをカスタマイズできます", + "theme_help_v2_1": "チェックボックスをONにすると、コンポーネントごとに、色と透明度をオーバーライドできます。「すべてクリア」ボタンを押すと、すべてのオーバーライドをやめます。", + "theme_help_v2_2": "バックグラウンドとテキストのコントラストを表すアイコンがあります。マウスをホバーすると、詳しい説明が出ます。透明な色を使っているときは、最悪の場合のコントラストが示されます。", + "tooltipRadius": "ツールチップとアラート", + "upload_a_photo": "画像をアップロード", + "user_settings": "ユーザー設定", + "values": { + "false": "いいえ", + "true": "はい" + }, + "notifications": "通知", + "notification_setting": "通知を受け取る:", + "notification_setting_follows": "あなたがフォローしているユーザーから", + "notification_setting_non_follows": "あなたがフォローしていないユーザーから", + "notification_setting_followers": "あなたをフォローしているユーザーから", + "notification_setting_non_followers": "あなたをフォローしていないユーザーから", + "notification_mutes": "特定のユーザーからの通知を止めるには、ミュートしてください。", + "notification_blocks": "ブロックしているユーザーからの通知は、すべて止まります。", + "enable_web_push_notifications": "ウェブプッシュ通知を許可する", + "style": { + "switcher": { + "keep_color": "色を残す", + "keep_shadows": "影を残す", + "keep_opacity": "透明度を残す", + "keep_roundness": "丸さを残す", + "keep_fonts": "フォントを残す", + "save_load_hint": "「残す」オプションをONにすると、テーマを選んだときとロードしたとき、現在の設定を残します。また、テーマをエクスポートするとき、これらのオプションを維持します。すべてのチェックボックスをOFFにすると、テーマをエクスポートしたとき、すべての設定を保存します。", + "reset": "リセット", + "clear_all": "すべてクリア", + "clear_opacity": "透明度をクリア" + }, + "common": { + "color": "色", + "opacity": "透明度", + "contrast": { + "hint": "コントラストは {ratio} です。{level}。({context})", + "level": { + "aa": "AAレベルガイドライン (ミニマル) を満たします", + "aaa": "AAAレベルガイドライン (レコメンデッド) を満たします。", + "bad": "ガイドラインを満たしません。" + }, + "context": { + "18pt": "大きい (18ポイント以上) テキスト", + "text": "テキスト" + } + } + }, + "common_colors": { + "_tab_label": "共通", + "main": "共通の色", + "foreground_hint": "「詳細」タブで、もっと細かく設定できます", + "rgbo": "アイコンとアクセントとバッジ" + }, + "advanced_colors": { + "_tab_label": "詳細", + "alert": "アラートのバックグラウンド", + "alert_error": "エラー", + "badge": "バッジのバックグラウンド", + "badge_notification": "通知", + "panel_header": "パネルヘッダー", + "top_bar": "トップバー", + "borders": "境界", + "buttons": "ボタン", + "inputs": "インプットフィールド", + "faint_text": "薄いテキスト" + }, + "radii": { + "_tab_label": "丸さ" + }, + "shadows": { + "_tab_label": "光と影", + "component": "コンポーネント", + "override": "オーバーライド", + "shadow_id": "影 #{value}", + "blur": "ぼかし", + "spread": "広がり", + "inset": "内側", + "hint": "影の設定では、色の値として --variable を使うことができます。これはCSS3変数です。ただし、透明度の設定は、効かなくなります。", + "filter_hint": { + "always_drop_shadow": "ブラウザーがサポートしていれば、常に {0} が使われます。", + "drop_shadow_syntax": "{0} は、{1} パラメーターと {2} キーワードをサポートしていません。", + "avatar_inset": "内側の影と外側の影を同時に使うと、透明なアバターの表示が乱れます。", + "spread_zero": "広がりが 0 よりも大きな影は、0 と同じです。", + "inset_classic": "内側の影は {0} を使います。" + }, + "components": { + "panel": "パネル", + "panelHeader": "パネルヘッダー", + "topBar": "トップバー", + "avatar": "ユーザーアバター (プロフィール)", + "avatarStatus": "ユーザーアバター (投稿)", + "popup": "ポップアップとツールチップ", + "button": "ボタン", + "buttonHover": "ボタン (ホバー)", + "buttonPressed": "ボタン (押されているとき)", + "buttonPressedHover": "ボタン (ホバー、かつ、押されているとき)", + "input": "インプットフィールド" + } + }, + "fonts": { + "_tab_label": "フォント", + "help": "「カスタム」を選んだときは、システムにあるフォントの名前を、正しく入力してください。", + "components": { + "interface": "インターフェース", + "input": "インプットフィールド", + "post": "投稿", + "postCode": "等幅 (投稿がリッチテキストであるとき)" + }, + "family": "フォント名", + "size": "大きさ (px)", + "weight": "太さ", + "custom": "カスタム" + }, + "preview": { + "header": "プレビュー", + "content": "本文", + "error": "エラーの例", + "button": "ボタン", + "text": "これは{0}と{1}の例です。", + "mono": "monospace", + "input": "羽田空港に着きました。", + "faint_link": "とても助けになるマニュアル", + "fine_print": "私たちの{0}を、読まないでください!", + "header_faint": "エラーではありません", + "checkbox": "利用規約を読みました", + "link": "ハイパーリンク" + } + }, + "version": { + "title": "バージョン", + "backend_version": "バックエンドのバージョン", + "frontend_version": "フロントエンドのバージョン" + } + }, + "timeline": { + "collapse": "たたむ", + "conversation": "スレッド", + "error_fetching": "読み込みがエラーになりました", + "load_older": "古いステータス", + "no_retweet_hint": "投稿を「フォロワーのみ」または「ダイレクト」にすると、リピートできなくなります", + "repeated": "リピート", + "show_new": "読み込み", + "up_to_date": "最新", + "no_more_statuses": "これで終わりです", + "no_statuses": "ステータスはありません" + }, + "status": { + "favorites": "お気に入り", + "repeats": "リピート", + "delete": "ステータスを削除", + "pin": "プロフィールにピン留め", + "unpin": "プロフィールのピン留めを外す", + "pinned": "ピン留め", + "delete_confirm": "本当にこのステータスを削除してもよろしいですか?", + "reply_to": "返信", + "replies_list": "返信:" + }, + "user_card": { + "approve": "受け入れ", + "block": "ブロック", + "blocked": "ブロックしています!", + "deny": "お断り", + "favorites": "お気に入り", + "follow": "フォロー", + "follow_sent": "リクエストを送りました!", + "follow_progress": "リクエストしています…", + "follow_again": "再びリクエストを送りますか?", + "follow_unfollow": "フォローをやめる", + "followees": "フォロー", + "followers": "フォロワー", + "following": "フォローしています!", + "follows_you": "フォローされました!", + "its_you": "これはあなたです!", + "media": "メディア", + "mute": "ミュート", + "muted": "ミュートしています!", + "per_day": "/日", + "remote_follow": "リモートフォロー", + "report": "通報", + "statuses": "ステータス", + "unblock": "ブロック解除", + "unblock_progress": "ブロックを解除しています...", + "block_progress": "ブロックしています...", + "unmute": "ミュート解除", + "unmute_progress": "ミュートを解除しています...", + "mute_progress": "ミュートしています...", + "admin_menu": { + "moderation": "モデレーション", + "grant_admin": "管理者権限を付与", + "revoke_admin": "管理者権限を解除", + "grant_moderator": "モデレーター権限を付与", + "revoke_moderator": "モデレーター権限を解除", + "activate_account": "アカウントをアクティブにする", + "deactivate_account": "アカウントをアクティブでなくする", + "delete_account": "アカウントを削除", + "force_nsfw": "すべての投稿をNSFWにする", + "strip_media": "投稿からメディアを除去する", + "force_unlisted": "投稿を未収載にする", + "sandbox": "投稿をフォロワーのみにする", + "disable_remote_subscription": "他のインスタンスからフォローされないようにする", + "disable_any_subscription": "フォローされないようにする", + "quarantine": "他のインスタンスからの投稿を止める", + "delete_user": "ユーザーを削除", + "delete_user_confirmation": "あなたの精神状態に何か問題はございませんか? この操作を取り消すことはできません。" + } + }, + "user_profile": { + "timeline_title": "ユーザータイムライン", + "profile_does_not_exist": "申し訳ない。このプロフィールは存在しません。", + "profile_loading_error": "申し訳ない。プロフィールの読み込みがエラーになりました。" + }, + "user_reporting": { + "title": "通報する: {0}", + "add_comment_description": "この通報は、あなたのインスタンスのモデレーターに送られます。このアカウントを通報する理由を説明することができます:", + "additional_comments": "追加のコメント", + "forward_description": "このアカウントは他のサーバーに置かれています。この通報のコピーをリモートのサーバーに送りますか?", + "forward_to": "転送する: {0}", + "submit": "送信", + "generic_error": "あなたのリクエストを処理しようとしましたが、エラーになりました。" + }, + "who_to_follow": { + "more": "詳細", + "who_to_follow": "おすすめユーザー" + }, + "tool_tip": { + "media_upload": "メディアをアップロード", + "repeat": "リピート", + "reply": "返信", + "favorite": "お気に入り", + "user_settings": "ユーザー設定" + }, + "upload":{ + "error": { + "base": "アップロードに失敗しました。", + "file_too_big": "ファイルが大きすぎます [{filesize} {filesizeunit} / {allowedsize} {allowedsizeunit}]", + "default": "しばらくしてから試してください" + }, + "file_size_units": { + "B": "B", + "KiB": "KiB", + "MiB": "MiB", + "GiB": "GiB", + "TiB": "TiB" + } + } +} diff --git a/src/i18n/messages.js b/src/i18n/messages.js index ab697948..404a4079 100644 --- a/src/i18n/messages.js +++ b/src/i18n/messages.js @@ -23,6 +23,7 @@ const messages = { hu: require('./hu.json'), it: require('./it.json'), ja: require('./ja.json'), + ja_pedantic: require('./ja_pedantic.json'), ko: require('./ko.json'), nb: require('./nb.json'), nl: require('./nl.json'), diff --git a/src/i18n/oc.json b/src/i18n/oc.json index ecc4df61..5f8d153f 100644 --- a/src/i18n/oc.json +++ b/src/i18n/oc.json @@ -2,6 +2,10 @@ "chat": { "title": "Messatjariá" }, + "exporter": { + "export": "Exportar", + "processing": "Tractament, vos demandarem lèu de telecargar lo fichièr" + }, "features_panel": { "chat": "Chat", "gopher": "Gopher", @@ -20,13 +24,22 @@ "submit": "Mandar", "more": "Mai", "generic_error": "Una error s’es producha", - "optional": "opcional" + "optional": "opcional", + "show_more": "Mostrar mai", + "show_less": "Mostrar mens", + "cancel": "Anullar" }, "image_cropper": { "crop_picture": "Talhar l’imatge", "save": "Salvar", + "save_without_cropping": "Salvar sens talhada", "cancel": "Anullar" }, + "importer": { + "submit": "Mandar", + "success": "Corrèctament importat.", + "error": "Una error s’es producha pendent l’importacion d’aqueste fichièr." + }, "login": { "login": "Connexion", "description": "Connexion via OAuth", @@ -73,11 +86,13 @@ "content_type": { "text/plain": "Tèxte brut", "text/html": "HTML", - "text/markdown": "Markdown" + "text/markdown": "Markdown", + "text/bbcode": "BBCode" }, "content_warning": "Avís de contengut (opcional)", "default": "Escrivètz aquí vòstre estatut.", - "direct_warning": "Aquesta publicacion serà pas que visibla pels utilizaires mencionats.", + "direct_warning_to_all": "Aquesta publicacion serà pas que visibla pels utilizaires mencionats.", + "direct_warning_to_first_only": "Aquesta publicacion serà pas que visibla pels utilizaires mencionats a la debuta del messatge.", "posting": "Mandadís", "scope": { "direct": "Dirècte - Publicar pels utilizaires mencionats solament", @@ -107,6 +122,9 @@ "password_confirmation_match": "deu èsser lo meteis senhal" } }, + "selectable_list": { + "select_all": "O seleccionar tot" + }, "settings": { "app_name": "Nom de l’aplicacion", "attachmentRadius": "Pèças juntas", @@ -117,6 +135,11 @@ "avatarRadius": "Avatars", "background": "Rèire plan", "bio": "Biografia", + "block_export": "Exportar los blocatges", + "block_export_button": "Exportar los blocatges dins un fichièr csv", + "block_import": "Impòrt de blocatges", + "block_import_error": "Error en importar los blocatges", + "blocks_imported": "Blocatges importats ! Lo tractament tardarà un pauc.", "blocks_tab": "Blocatges", "btnRadius": "Botons", "cBlue": "Blau (Respondre, seguir)", @@ -144,7 +167,6 @@ "filtering_explanation": "Totes los estatuts amb aqueles mots seràn en silenci, un mot per linha", "follow_export": "Exportar los abonaments", "follow_export_button": "Exportar vòstres abonaments dins un fichièr csv", - "follow_export_processing": "Tractament, vos demandarem lèu de telecargar lo fichièr", "follow_import": "Importar los abonaments", "follow_import_error": "Error en important los seguidors", "follows_imported": "Seguidors importats. Lo tractament pòt trigar una estona.", @@ -152,6 +174,7 @@ "general": "General", "hide_attachments_in_convo": "Rescondre las pèças juntas dins las conversacions", "hide_attachments_in_tl": "Rescondre las pèças juntas", + "hide_muted_posts": "Rescondre las publicacions del monde rescondut", "max_thumbnails": "Nombre maximum de vinhetas per publicacion", "hide_isp": "Amagar lo panèl especial instància", "preload_images": "Precargar los imatges", @@ -178,6 +201,7 @@ "use_contain_fit": "Talhar pas las pèças juntas per las vinhetas", "name": "Nom", "name_bio": "Nom & Bio", + "new_password": "Nòu senhal", "notification_visibility_follows": "Abonaments", "notification_visibility_likes": "Aimar", @@ -211,8 +235,11 @@ "reply_visibility_self": "Mostrar pas que las responsas que me son destinadas", "saving_err": "Error en enregistrant los paramètres", "saving_ok": "Paramètres enregistrats", - "scope_copy": "Copiar lo nivèl de confidencialitat per las responsas (Totjorn aissí pels Messatges Dirèctes)", + "search_user_to_block": "Cercatz qual volètz blocar", + "search_user_to_mute": "Cercatz qual volètz rescondre", "security_tab": "Seguretat", + "scope_copy": "Copiar lo nivèl de confidencialitat per las responsas (Totjorn aissí pels Messatges Dirèctes)", + "minimal_scopes_mode": "Minimizar lo nombre d’opcions per publicacion", "set_new_avatar": "Definir un nòu avatar", "set_new_profile_background": "Definir un nòu fons de perfil", "set_new_profile_banner": "Definir una nòva bandièra de perfil", @@ -347,6 +374,11 @@ "checkbox": "Ai legit los tèrmes e condicions d’utilizacion", "link": "un pichon ligam simpatic" } + }, + "version": { + "title": "Version", + "backend_version": "Version Backend", + "frontend_version": "Version Frontend" } }, "timeline": { @@ -362,6 +394,8 @@ "no_statuses": "Cap d’estatuts" }, "status": { + "favorites": "Li a agradat", + "repeats": "A repetit", "reply_to": "Respond a", "replies_list": "Responsas :" }, @@ -392,7 +426,26 @@ "block_progress": "Blocatge...", "unmute": "Tornar mostrar", "unmute_progress": "Afichatge...", - "mute_progress": "A amagar..." + "mute_progress": "A amagar...", + "admin_menu": { + "moderation": "Moderacion", + "grant_admin": "Passar Admin", + "revoke_admin": "Revocar Admin", + "grant_moderator": "Passar Moderator", + "revoke_moderator": "Revocar Moderator", + "activate_account": "Activar lo compte", + "deactivate_account": "Desactivar lo compte", + "delete_account": "Suprimir lo compte", + "force_nsfw": "Marcar totas las publicacions coma sensiblas", + "strip_media": "Tirar los mèdias de las publicacions", + "force_unlisted": "Forçar las publicacions en pas-listadas", + "sandbox": "Forçar las publicacions en seguidors solament", + "disable_remote_subscription": "Desactivar lo seguiment d’utilizaire d’instàncias alonhadas", + "disable_any_subscription": "Desactivar tot seguiment", + "quarantine": "Defendre la federacion de las publicacions de l’utilizaire", + "delete_user": "Suprimir l’utilizaire", + "delete_user_confirmation": "Volètz vertadièrament far aquò ? Aquesta accion se pòt pas anullar." + } }, "user_profile": { "timeline_title": "Flux utilizaire", diff --git a/src/i18n/pl.json b/src/i18n/pl.json index 2e1d7488..51cadfb6 100644 --- a/src/i18n/pl.json +++ b/src/i18n/pl.json @@ -2,48 +2,115 @@ "chat": { "title": "Czat" }, + "features_panel": { + "chat": "Czat", + "gopher": "Gopher", + "media_proxy": "Proxy mediów", + "scope_options": "Ustawienia zakresu", + "text_limit": "Limit tekstu", + "title": "Funkcje", + "who_to_follow": "Propozycje obserwacji" + }, "finder": { "error_fetching_user": "Błąd przy pobieraniu profilu", "find_user": "Znajdź użytkownika" }, "general": { "apply": "Zastosuj", - "submit": "Wyślij" + "submit": "Wyślij", + "more": "Więcej", + "generic_error": "Wystąpił błąd", + "optional": "nieobowiązkowe" + }, + "image_cropper": { + "crop_picture": "Przytnij obrazek", + "save": "Zapisz", + "save_without_cropping": "Zapisz bez przycinania", + "cancel": "Anuluj" }, "login": { "login": "Zaloguj", + "description": "Zaloguj używając OAuth", "logout": "Wyloguj", "password": "Hasło", "placeholder": "n.p. lain", "register": "Zarejestruj", - "username": "Użytkownik" + "username": "Użytkownik", + "hint": "Zaloguj się, aby dołączyć do dyskusji" + }, + "media_modal": { + "previous": "Poprzednie", + "next": "Następne" }, "nav": { + "about": "O nas", + "back": "Wróć", "chat": "Lokalny czat", + "friend_requests": "Prośby o możliwość obserwacji", "mentions": "Wzmianki", + "dms": "Wiadomości prywatne", "public_tl": "Publiczna oś czasu", "timeline": "Oś czasu", - "twkn": "Cała znana sieć" + "twkn": "Cała znana sieć", + "user_search": "Wyszukiwanie użytkowników", + "who_to_follow": "Sugestie obserwacji", + "preferences": "Preferencje" }, "notifications": { - "favorited_you": "dodał twój status do ulubionych", + "broken_favorite": "Nieznany status, szukam go…", + "favorited_you": "dodał(-a) twój status do ulubionych", "followed_you": "obserwuje cię", + "load_older": "Załaduj starsze powiadomienia", "notifications": "Powiadomienia", "read": "Przeczytane!", - "repeated_you": "powtórzył twój status" + "repeated_you": "powtórzył(-a) twój status", + "no_more_notifications": "Nie masz więcej powiadomień" }, "post_status": { + "new_status": "Dodaj nowy status", + "account_not_locked_warning": "Twoje konto nie jest {0}. Każdy może cię zaobserwować aby zobaczyć wpisy tylko dla obserwujących.", + "account_not_locked_warning_link": "zablokowane", + "attachments_sensitive": "Oznacz załączniki jako wrażliwe", + "content_type": { + "text/plain": "Czysty tekst", + "text/html": "HTML", + "text/markdown": "Markdown", + "text/bbcode": "BBCode" + }, + "content_warning": "Temat (nieobowiązkowy)", "default": "Właśnie wróciłem z kościoła", - "posting": "Wysyłanie" + "direct_warning": "Ten wpis zobaczą tylko osoby, o których wspomniałeś(-aś).", + "posting": "Wysyłanie", + "scope": { + "direct": "Bezpośredni – Tylko dla wspomnianych użytkowników", + "private": "Tylko dla obserwujących – Umieść dla osób, które cię obserwują", + "public": "Publiczny – Umieść na publicznych osiach czasu", + "unlisted": "Niewidoczny – Nie umieszczaj na publicznych osiach czasu" + } }, "registration": { "bio": "Bio", - "email": "Email", + "email": "E-mail", "fullname": "Wyświetlana nazwa profilu", "password_confirm": "Potwierdzenie hasła", - "registration": "Rejestracja" + "registration": "Rejestracja", + "token": "Token zaproszenia", + "captcha": "CAPTCHA", + "new_captcha": "Naciśnij na obrazek, aby dostać nowy kod captcha", + "username_placeholder": "np. lain", + "fullname_placeholder": "np. Lain Iwakura", + "bio_placeholder": "e.g.\nCześć, jestem Lain.\nJestem dziewczynką z anime żyjącą na peryferiach Japonii. Możesz znać mnie z Wired.", + "validations": { + "username_required": "nie może być pusta", + "fullname_required": "nie może być pusta", + "email_required": "nie może być pusty", + "password_required": "nie może być puste", + "password_confirmation_required": "nie może być puste", + "password_confirmation_match": "musi być takie jak hasło" + } }, "settings": { + "app_name": "Nazwa aplikacji", "attachmentRadius": "Załączniki", "attachments": "Załączniki", "autoload": "Włącz automatyczne ładowanie po przewinięciu do końca strony", @@ -52,6 +119,7 @@ "avatarRadius": "Awatary", "background": "Tło", "bio": "Bio", + "blocks_tab": "Bloki", "btnRadius": "Przyciski", "cBlue": "Niebieski (odpowiedz, obserwuj)", "cGreen": "Zielony (powtórzenia)", @@ -59,15 +127,21 @@ "cRed": "Czerwony (anuluj)", "change_password": "Zmień hasło", "change_password_error": "Podczas zmiany hasła wystąpił problem.", - "changed_password": "Hasło zmienione poprawnie!", + "changed_password": "Pomyślnie zmieniono hasło!", + "collapse_subject": "Zwijaj posty z tematami", + "composing": "Pisanie", "confirm_new_password": "Potwierdź nowe hasło", "current_avatar": "Twój obecny awatar", "current_password": "Obecne hasło", "current_profile_banner": "Twój obecny banner profilu", + "data_import_export_tab": "Import/eksport danych", + "default_vis": "Domyślny zakres widoczności", "delete_account": "Usuń konto", "delete_account_description": "Trwale usuń konto i wszystkie posty.", "delete_account_error": "Wystąpił problem z usuwaniem twojego konta. Jeżeli problem powtarza się, poinformuj administratora swojej instancji.", "delete_account_instructions": "Wprowadź swoje hasło w poniższe pole aby potwierdzić usunięcie konta.", + "avatar_size_instruction": "Zalecany minimalny rozmiar awatarów to 150x150 pikseli.", + "export_theme": "Zapisz motyw", "filtering": "Filtrowanie", "filtering_explanation": "Wszystkie statusy zawierające te słowa będą wyciszone. Jedno słowo na linijkę.", "follow_export": "Eksport obserwowanych", @@ -77,14 +151,49 @@ "follow_import_error": "Błąd przy importowaniu obserwowanych", "follows_imported": "Obserwowani zaimportowani! Przetwarzanie może trochę potrwać.", "foreground": "Pierwszy plan", - "hide_attachments_in_convo": "Ukryj załączniki w rozmowach", - "hide_attachments_in_tl": "Ukryj załączniki w osi czasu", + "general": "Ogólne", + "hide_attachments_in_convo": "Ukrywaj załączniki w rozmowach", + "hide_attachments_in_tl": "Ukrywaj załączniki w osi czasu", + "hide_muted_posts": "Ukrywaj wpisy wyciszonych użytkowników", + "max_thumbnails": "Maksymalna liczba miniatur w poście", + "hide_isp": "Ukryj panel informacji o instancji", + "preload_images": "Ładuj wstępnie obrazy", + "use_one_click_nsfw": "Otwieraj załączniki NSFW jednym kliknięciem", + "hide_post_stats": "Ukrywaj statysyki postów (np. liczbę polubień)", + "hide_user_stats": "Ukrywaj statysyki użytkowników (np. liczbę obserwujących)", + "hide_filtered_statuses": "Ukrywaj filtrowane statusy", "import_followers_from_a_csv_file": "Importuj obserwowanych z pliku CSV", + "import_theme": "Załaduj motyw", "inputRadius": "Pola tekstowe", + "checkboxRadius": "Pola wyboru", + "instance_default": "(domyślny: {value})", + "instance_default_simple": "(domyślny)", + "interface": "Interfejs", + "interfaceLanguage": "Język interfejsu", + "invalid_theme_imported": "Wybrany plik nie jest obsługiwanym motywem Pleromy. Nie dokonano zmian w twoim motywie.", + "limited_availability": "Niedostępne w twojej przeglądarce", "links": "Łącza", + "lock_account_description": "Ogranicz swoje konto dla zatwierdzonych obserwowanych", + "loop_video": "Zapętlaj filmy", + "loop_video_silent_only": "Zapętlaj tylko filmy bez dźwięku (np. mastodonowe „gify”)", + "mutes_tab": "Wyciszenia", + "play_videos_in_modal": "Odtwarzaj filmy bezpośrednio w przeglądarce mediów", + "use_contain_fit": "Nie przycinaj załączników na miniaturach", "name": "Imię", "name_bio": "Imię i bio", "new_password": "Nowe hasło", + "notification_visibility": "Rodzaje powiadomień do wyświetlania", + "notification_visibility_follows": "Obserwacje", + "notification_visibility_likes": "Ulubione", + "notification_visibility_mentions": "Wzmianki", + "notification_visibility_repeats": "Powtórzenia", + "no_rich_text_description": "Usuwaj formatowanie ze wszystkich postów", + "no_blocks": "Bez blokad", + "no_mutes": "Bez wyciszeń", + "hide_follows_description": "Nie pokazuj kogo obserwuję", + "hide_followers_description": "Nie pokazuj kto mnie obserwuje", + "show_admin_badge": "Pokazuj odznakę Administrator na moim profilu", + "show_moderator_badge": "Pokazuj odznakę Moderator na moim profilu", "nsfw_clickthrough": "Włącz domyślne ukrywanie załączników o treści nieprzyzwoitej (NSFW)", "oauth_tokens": "Tokeny OAuth", "token": "Token", @@ -92,47 +201,235 @@ "valid_until": "Ważne do", "revoke_token": "Odwołać", "panelRadius": "Panele", + "pause_on_unfocused": "Wstrzymuj strumieniowanie kiedy karta nie jest aktywna", "presets": "Gotowe motywy", "profile_background": "Tło profilu", "profile_banner": "Banner profilu", + "profile_tab": "Profil", "radii_help": "Ustaw zaokrąglenie krawędzi interfejsu (w pikselach)", + "replies_in_timeline": "Odpowiedzi na osi czasu", "reply_link_preview": "Włącz dymek z podglądem postu po najechaniu na znak odpowiedzi", + "reply_visibility_all": "Pokazuj wszystkie odpowiedzi", + "reply_visibility_following": "Pokazuj tylko odpowiedzi skierowane do mnie i osób które obserwuję", + "reply_visibility_self": "Pokazuj tylko odpowiedzi skierowane do mnie", + "saving_err": "Nie udało się zapisać ustawień", + "saving_ok": "Zapisano ustawienia", + "security_tab": "Bezpieczeństwo", + "scope_copy": "Kopiuj zakres podczas odpowiadania (DM-y zawsze są kopiowane)", "set_new_avatar": "Ustaw nowy awatar", "set_new_profile_background": "Ustaw nowe tło profilu", "set_new_profile_banner": "Ustaw nowy banner profilu", "settings": "Ustawienia", + "subject_input_always_show": "Zawsze pokazuj pole tematu", + "subject_line_behavior": "Kopiuj temat podczas odpowiedzi", + "subject_line_email": "Jak w mailach – „re: temat”", + "subject_line_mastodon": "Jak na Mastodonie – po prostu kopiuj", + "subject_line_noop": "Nie kopiuj", + "post_status_content_type": "Post status content type", "stop_gifs": "Odtwarzaj GIFy po najechaniu kursorem", - "streaming": "Włącz automatycznie strumieniowanie nowych postów gdy na początku strony", + "streaming": "Włącz automatycznie strumieniowanie nowych postów gdy jesteś na początku strony", "text": "Tekst", "theme": "Motyw", "theme_help": "Użyj kolorów w notacji szesnastkowej (#rrggbb), by stworzyć swój motyw.", + "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", - "user_settings": "Ustawienia użytkownika" + "upload_a_photo": "Wyślij zdjęcie", + "user_settings": "Ustawienia użytkownika", + "values": { + "false": "nie", + "true": "tak" + }, + "notifications": "Powiadomienia", + "enable_web_push_notifications": "Włącz powiadomienia push", + "style": { + "switcher": { + "keep_color": "Zachowaj kolory", + "keep_shadows": "Zachowaj cienie", + "keep_opacity": "Zachowaj widoczność", + "keep_roundness": "Zachowaj zaokrąglenie", + "keep_fonts": "Zachowaj czcionki", + "save_load_hint": "Opcje „zachowaj” pozwalają na pozostanie przy obecnych opcjach po wybraniu lub załadowaniu motywu, jak i przechowywanie ich podczas eksportowania motywu. Jeżeli wszystkie są odznaczone, eksportowanie motywu spowoduje zapisanie wszystkiego.", + "reset": "Wyzeruj", + "clear_all": "Wyczyść wszystko", + "clear_opacity": "Wyczyść widoczność" + }, + "common": { + "color": "Kolor", + "opacity": "Widoczność", + "contrast": { + "hint": "Współczynnik kontrastu wynosi {ratio}, {level} {context}", + "level": { + "aa": "spełnia wymogi poziomu AA (minimalne)", + "aaa": "spełnia wymogi poziomu AAA (zalecane)", + "bad": "nie spełnia żadnych wymogów dostępności" + }, + "context": { + "18pt": "dla dużego tekstu (18pt+)", + "text": "dla tekstu" + } + } + }, + "common_colors": { + "_tab_label": "Ogólne", + "main": "Ogólne kolory", + "foreground_hint": "Zajrzyj do karty „Zaawansowane”, aby uzyskać dokładniejszą kontrolę", + "rgbo": "Ikony, wyróżnienia, odznaki" + }, + "advanced_colors": { + "_tab_label": "Zaawansowane", + "alert": "Tło alertu", + "alert_error": "Błąd", + "badge": "Tło odznaki", + "badge_notification": "Powiadomienie", + "panel_header": "Nagłówek panelu", + "top_bar": "Górny pasek", + "borders": "Granice", + "buttons": "Przyciski", + "inputs": "Pola wejścia", + "faint_text": "Zanikający tekst" + }, + "radii": { + "_tab_label": "Zaokrąglenie" + }, + "shadows": { + "_tab_label": "Cień i podświetlenie", + "component": "Komponent", + "override": "Zastąp", + "shadow_id": "Cień #{value}", + "blur": "Rozmycie", + "spread": "Szerokość", + "inset": "Inset", + "hint": "Możesz też używać --zmiennych jako kolorów, aby wykorzystać zmienne CSS3. Pamiętaj, że ustawienie widoczności nie będzie wtedy działać.", + "filter_hint": { + "always_drop_shadow": "Ostrzeżenie, ten cień zawsze używa {0} jeżeli to obsługiwane przez przeglądarkę.", + "drop_shadow_syntax": "{0} nie obsługuje parametru {1} i słowa kluczowego {2}.", + "avatar_inset": "Pamiętaj że użycie jednocześnie cieni inset i nie inset na awatarach może daćnieoczekiwane wyniki z przezroczystymi awatarami.", + "spread_zero": "Cienie o ujemnej szerokości będą widoczne tak, jakby wynosiła ona zero", + "inset_classic": "Cienie inset będą używały {0}" + }, + "components": { + "panel": "Panel", + "panelHeader": "Nagłówek panelu", + "topBar": "Górny pasek", + "avatar": "Awatar użytkownika (w widoku profilu)", + "avatarStatus": "Awatar użytkownika (w widoku wpisu)", + "popup": "Wyskakujące okna i podpowiedzi", + "button": "Przycisk", + "buttonHover": "Przycisk (po najechaniu)", + "buttonPressed": "Przycisk (naciśnięty)", + "buttonPressedHover": "Przycisk(naciśnięty+najechany)", + "input": "Pole wejścia" + } + }, + "fonts": { + "_tab_label": "Czcionki", + "help": "Wybierz czcionkę używaną przez elementy UI. Jeżeli wybierzesz niestandardową, musisz wpisać dokładnie tę nazwę, pod którą pojawia się w systemie.", + "components": { + "interface": "Interfejs", + "input": "Pola wejścia", + "post": "Tekst postu", + "postCode": "Tekst o stałej szerokości znaków w sformatowanym poście" + }, + "family": "Nazwa czcionki", + "size": "Rozmiar (w pikselach)", + "weight": "Grubość", + "custom": "Niestandardowa" + }, + "preview": { + "header": "Podgląd", + "content": "Zawartość", + "error": "Przykładowy błąd", + "button": "Przycisk", + "text": "Trochę więcej {0} i {1}", + "mono": "treści", + "input": "Właśnie wróciłem z kościoła", + "faint_link": "pomocny podręcznik", + "fine_print": "Przeczytaj nasz {0}, aby nie nauczyć się niczego przydatnego!", + "header_faint": "W porządku", + "checkbox": "Przeleciałem przez zasady użytkowania", + "link": "i fajny mały odnośnik" + } + }, + "version": { + "title": "Wersja", + "backend_version": "Wersja back-endu", + "frontend_version": "Wersja front-endu" + } }, "timeline": { "collapse": "Zwiń", "conversation": "Rozmowa", "error_fetching": "Błąd pobierania", "load_older": "Załaduj starsze statusy", - "repeated": "powtórzono", + "no_retweet_hint": "Wpis oznaczony jako tylko dla obserwujących lub bezpośredni nie może zostać powtórzony", + "repeated": "powtórzył(-a)", "show_new": "Pokaż nowe", - "up_to_date": "Na bieżąco" + "up_to_date": "Na bieżąco", + "no_more_statuses": "Brak kolejnych statusów", + "no_statuses": "Brak statusów" + }, + "status": { + "reply_to": "Odpowiedź dla", + "replies_list": "Odpowiedzi:" }, "user_card": { + "approve": "Przyjmij", "block": "Zablokuj", "blocked": "Zablokowany!", + "deny": "Odrzuć", + "favorites": "Ulubione", "follow": "Obserwuj", + "follow_sent": "Wysłano prośbę!", + "follow_progress": "Wysyłam prośbę…", + "follow_again": "Wysłać prośbę ponownie?", + "follow_unfollow": "Przestań obserwować", "followees": "Obserwowani", "followers": "Obserwujący", "following": "Obserwowany!", "follows_you": "Obserwuje cię!", + "its_you": "To ty!", + "media": "Media", "mute": "Wycisz", - "muted": "Wyciszony", + "muted": "Wyciszony(-a)", "per_day": "dziennie", "remote_follow": "Zdalna obserwacja", - "statuses": "Statusy" + "statuses": "Statusy", + "unblock": "Odblokuj", + "unblock_progress": "Odblokowuję…", + "block_progress": "Blokuję…", + "unmute": "Cofnij wyciszenie", + "unmute_progress": "Cofam wyciszenie…", + "mute_progress": "Wyciszam…" }, "user_profile": { - "timeline_title": "Oś czasu użytkownika" + "timeline_title": "Oś czasu użytkownika", + "profile_does_not_exist": "Przepraszamy, ten profil nie istnieje.", + "profile_loading_error": "Przepraszamy, wystąpił błąd podczas ładowania tego profilu." + }, + "who_to_follow": { + "more": "Więcej", + "who_to_follow": "Propozycje obserwacji" + }, + "tool_tip": { + "media_upload": "Wyślij media", + "repeat": "Powtórz", + "reply": "Odpowiedz", + "favorite": "Dodaj do ulubionych", + "user_settings": "Ustawienia użytkownika" + }, + "upload":{ + "error": { + "base": "Wysyłanie nie powiodło się.", + "file_too_big": "Zbyt duży plik [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]", + "default": "Spróbuj ponownie później" + }, + "file_size_units": { + "B": "B", + "KiB": "KiB", + "MiB": "MiB", + "GiB": "GiB", + "TiB": "TiB" + } } } diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 6799cc96..d24ef0cb 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -8,7 +8,12 @@ }, "general": { "apply": "Применить", - "submit": "Отправить" + "submit": "Отправить", + "cancel": "Отмена", + "disable": "Оключить", + "enable": "Включить", + "confirm": "Подтвердить", + "verify": "Проверить" }, "login": { "login": "Войти", @@ -16,12 +21,21 @@ "password": "Пароль", "placeholder": "e.c. lain", "register": "Зарегистрироваться", - "username": "Имя пользователя" + "username": "Имя пользователя", + "authentication_code": "Код аутентификации", + "enter_recovery_code": "Ввести код восстановления", + "enter_two_factor_code": "Ввести код аутентификации", + "recovery_code": "Код восстановления", + "heading" : { + "TotpForm" : "Двухфакторная аутентификация", + "RecoveryForm" : "Two-factor recovery" + } }, "nav": { "back": "Назад", "chat": "Локальный чат", "mentions": "Упоминания", + "interactions": "Взаимодействия", "public_tl": "Публичная лента", "timeline": "Лента", "twkn": "Федеративная лента" @@ -35,14 +49,24 @@ "read": "Прочесть", "repeated_you": "повторил(а) ваш статус" }, + "interactions": { + "favs_repeats": "Повторы и фавориты", + "follows": "Новые подписки", + "load_older": "Загрузить старые взаимодействия" + }, "post_status": { "account_not_locked_warning": "Ваш аккаунт не {0}. Кто угодно может зафоловить вас чтобы прочитать посты только для подписчиков", "account_not_locked_warning_link": "залочен", "attachments_sensitive": "Вложения содержат чувствительный контент", "content_warning": "Тема (не обязательно)", "default": "Что нового?", - "direct_warning": "Этот пост будет видет только упомянутым пользователям", + "direct_warning": "Этот пост будет виден только упомянутым пользователям", "posting": "Отправляется", + "scope_notice": { + "public": "Этот пост будет виден всем", + "private": "Этот пост будет виден только вашим подписчикам", + "unlisted": "Этот пост не будет виден в публичной и федеративной ленте" + }, "scope": { "direct": "Личное - этот пост видят только те кто в нём упомянут", "private": "Для подписчиков - этот пост видят только подписчики", @@ -67,6 +91,28 @@ } }, "settings": { + "enter_current_password_to_confirm": "Введите свой текущий пароль", + "mfa": { + "otp" : "OTP", + "setup_otp" : "Настройка OTP", + "wait_pre_setup_otp" : "предварительная настройка OTP", + "confirm_and_enable" : "Подтвердить и включить OTP", + "title": "Двухфакторная аутентификация", + "generate_new_recovery_codes" : "Получить новые коды востановления", + "warning_of_generate_new_codes" : "После получения новых кодов восстановления, старые больше не будут работать.", + "recovery_codes" : "Коды восстановления.", + "waiting_a_recovery_codes": "Получение кодов восстановления ...", + "recovery_codes_warning" : "Запишите эти коды и держите в безопасном месте - иначе вы их больше не увидите. Если вы потеряете доступ к OTP приложению - без резервных кодов вы больше не сможете залогиниться.", + "authentication_methods" : "Методы аутентификации", + "scan": { + "title": "Сканирование", + "desc": "Используйте приложение для двухэтапной аутентификации для сканирования этого QR-код или введите текстовый ключ:", + "secret_code": "Ключ" + }, + "verify": { + "desc": "Чтобы включить двухэтапную аутентификации, введите код из вашего приложение для двухэтапной аутентификации:" + } + }, "attachmentRadius": "Прикреплённые файлы", "attachments": "Вложения", "autoload": "Включить автоматическую загрузку при прокрутке вниз", @@ -111,6 +157,8 @@ "import_theme": "Загрузить Тему", "inputRadius": "Поля ввода", "checkboxRadius": "Чекбоксы", + "instance_default": "(по умолчанию: {value})", + "instance_default_simple": "(по умолчанию)", "interface": "Интерфейс", "interfaceLanguage": "Язык интерфейса", "limited_availability": "Не доступно в вашем браузере", @@ -149,7 +197,12 @@ "reply_visibility_all": "Показывать все ответы", "reply_visibility_following": "Показывать только ответы мне и тех на кого я подписан", "reply_visibility_self": "Показывать только ответы мне", + "autohide_floating_post_button": "Автоматически скрывать кнопку постинга (в мобильной версии)", + "saving_err": "Не удалось сохранить настройки", + "saving_ok": "Сохранено", "security_tab": "Безопасность", + "scope_copy": "Копировать видимость поста при ответе (всегда включено для Личных Сообщений)", + "minimal_scopes_mode": "Минимизировать набор опций видимости поста", "set_new_avatar": "Загрузить новый аватар", "set_new_profile_background": "Загрузить новый фон профиля", "set_new_profile_banner": "Загрузить новый баннер профиля", @@ -164,6 +217,10 @@ "theme_help_v2_2": "Под некоторыми полями ввода это идикаторы контрастности, наведите на них мышью чтобы узнать больше. Приспользовании прозрачности контраст расчитывается для наихудшего варианта.", "tooltipRadius": "Всплывающие подсказки/уведомления", "user_settings": "Настройки пользователя", + "values": { + "false": "нет", + "true": "да" + }, "style": { "switcher": { "keep_color": "Оставить цвета", @@ -301,7 +358,26 @@ "muted": "Игнорирую", "per_day": "в день", "remote_follow": "Читать удалённо", - "statuses": "Статусы" + "statuses": "Статусы", + "admin_menu": { + "moderation": "Опции модератора", + "grant_admin": "Сделать администратором", + "revoke_admin": "Забрать права администратора", + "grant_moderator": "Сделать модератором", + "revoke_moderator": "Забрать права модератора", + "activate_account": "Активировать аккаунт", + "deactivate_account": "Деактивировать аккаунт", + "delete_account": "Удалить аккаунт", + "force_nsfw": "Отмечать посты пользователя как NSFW", + "strip_media": "Убирать вложения из постов пользователя", + "force_unlisted": "Не добавлять посты в публичные ленты", + "sandbox": "Посты доступны только для подписчиков", + "disable_remote_subscription": "Запретить подписываться с удаленных серверов", + "disable_any_subscription": "Запретить подписываться на пользователя", + "quarantine": "Не федерировать посты пользователя", + "delete_user": "Удалить пользователя", + "delete_user_confirmation": "Вы уверены? Это действие нельзя отменить." + } }, "user_profile": { "timeline_title": "Лента пользователя" diff --git a/src/main.js b/src/main.js index 9ffc3727..5758c7bd 100644 --- a/src/main.js +++ b/src/main.js @@ -10,8 +10,10 @@ import apiModule from './modules/api.js' import configModule from './modules/config.js' import chatModule from './modules/chat.js' import oauthModule from './modules/oauth.js' +import authFlowModule from './modules/auth_flow.js' import mediaViewerModule from './modules/media_viewer.js' import oauthTokensModule from './modules/oauth_tokens.js' +import reportsModule from './modules/reports.js' import VueTimeago from 'vue-timeago' import VueI18n from 'vue-i18n' @@ -22,6 +24,8 @@ import pushNotifications from './lib/push_notifications_plugin.js' import messages from './i18n/messages.js' import VueChatScroll from 'vue-chat-scroll' +import VueClickOutside from 'v-click-outside' +import PortalVue from 'portal-vue' import afterStoreSetup from './boot/after_store.js' @@ -39,6 +43,8 @@ Vue.use(VueTimeago, { }) Vue.use(VueI18n) Vue.use(VueChatScroll) +Vue.use(VueClickOutside) +Vue.use(PortalVue) const i18n = new VueI18n({ // By default, use the browser locale, we will update it if neccessary @@ -59,6 +65,11 @@ const persistedStateOptions = { const persistedState = await createPersistedState(persistedStateOptions) const store = new Vuex.Store({ modules: { + i18n: { + getters: { + i18n: () => i18n + } + }, interface: interfaceModule, instance: instanceModule, statuses: statusesModule, @@ -67,8 +78,10 @@ const persistedStateOptions = { config: configModule, chat: chatModule, oauth: oauthModule, + authFlow: authFlowModule, mediaViewer: mediaViewerModule, - oauthTokens: oauthTokensModule + oauthTokens: oauthTokensModule, + reports: reportsModule }, plugins: [persistedState, pushNotifications], strict: false // Socket modifies itself, let's ignore this for now. diff --git a/src/modules/api.js b/src/modules/api.js index 31cb55c6..7ed3edac 100644 --- a/src/modules/api.js +++ b/src/modules/api.js @@ -13,11 +13,11 @@ const api = { setBackendInteractor (state, backendInteractor) { state.backendInteractor = backendInteractor }, - addFetcher (state, {timeline, fetcher}) { - state.fetchers[timeline] = fetcher + addFetcher (state, { fetcherName, fetcher }) { + state.fetchers[fetcherName] = fetcher }, - removeFetcher (state, {timeline}) { - delete state.fetchers[timeline] + removeFetcher (state, { fetcherName }) { + delete state.fetchers[fetcherName] }, setWsToken (state, token) { state.wsToken = token @@ -33,17 +33,24 @@ const api = { } }, actions: { - startFetching (store, {timeline = 'friends', tag = false, userId = false}) { + startFetchingTimeline (store, { timeline = 'friends', tag = false, userId = false }) { // Don't start fetching if we already are. if (store.state.fetchers[timeline]) return - const fetcher = store.state.backendInteractor.startFetching({ timeline, store, userId, tag }) - store.commit('addFetcher', { timeline, fetcher }) + const fetcher = store.state.backendInteractor.startFetchingTimeline({ timeline, store, userId, tag }) + store.commit('addFetcher', { fetcherName: timeline, fetcher }) }, - stopFetching (store, timeline) { - const fetcher = store.state.fetchers[timeline] + startFetchingNotifications (store) { + // Don't start fetching if we already are. + if (store.state.fetchers['notifications']) return + + const fetcher = store.state.backendInteractor.startFetchingNotifications({ store }) + store.commit('addFetcher', { fetcherName: 'notifications', fetcher }) + }, + stopFetching (store, fetcherName) { + const fetcher = store.state.fetchers[fetcherName] window.clearInterval(fetcher) - store.commit('removeFetcher', {timeline}) + store.commit('removeFetcher', { fetcherName }) }, setWsToken (store, token) { store.commit('setWsToken', token) diff --git a/src/modules/auth_flow.js b/src/modules/auth_flow.js new file mode 100644 index 00000000..86328cf3 --- /dev/null +++ b/src/modules/auth_flow.js @@ -0,0 +1,89 @@ +const PASSWORD_STRATEGY = 'password' +const TOKEN_STRATEGY = 'token' + +// MFA strategies +const TOTP_STRATEGY = 'totp' +const RECOVERY_STRATEGY = 'recovery' + +// initial state +const state = { + app: null, + settings: {}, + strategy: PASSWORD_STRATEGY, + initStrategy: PASSWORD_STRATEGY // default strategy from config +} + +const resetState = (state) => { + state.strategy = state.initStrategy + state.settings = {} + state.app = null +} + +// getters +const getters = { + app: (state, getters) => { + return state.app + }, + settings: (state, getters) => { + return state.settings + }, + requiredPassword: (state, getters, rootState) => { + return state.strategy === PASSWORD_STRATEGY + }, + requiredToken: (state, getters, rootState) => { + return state.strategy === TOKEN_STRATEGY + }, + requiredTOTP: (state, getters, rootState) => { + return state.strategy === TOTP_STRATEGY + }, + requiredRecovery: (state, getters, rootState) => { + return state.strategy === RECOVERY_STRATEGY + } +} + +// mutations +const mutations = { + setInitialStrategy (state, strategy) { + if (strategy) { + state.initStrategy = strategy + state.strategy = strategy + } + }, + requirePassword (state) { + state.strategy = PASSWORD_STRATEGY + }, + requireToken (state) { + state.strategy = TOKEN_STRATEGY + }, + requireMFA (state, {app, settings}) { + state.settings = settings + state.app = app + state.strategy = TOTP_STRATEGY // default strategy of MFA + }, + requireRecovery (state) { + state.strategy = RECOVERY_STRATEGY + }, + requireTOTP (state) { + state.strategy = TOTP_STRATEGY + }, + abortMFA (state) { + resetState(state) + } +} + +// actions +const actions = { + async login ({state, dispatch, commit}, {access_token}) { + commit('setToken', access_token, { root: true }) + await dispatch('loginUser', access_token, { root: true }) + resetState(state) + } +} + +export default { + namespaced: true, + state, + getters, + mutations, + actions +} diff --git a/src/modules/config.js b/src/modules/config.js index c5491c01..a8da525a 100644 --- a/src/modules/config.js +++ b/src/modules/config.js @@ -17,6 +17,7 @@ const defaultState = { autoLoad: true, streaming: false, hoverPreview: true, + autohideFloatingPostButton: false, pauseOnUnfocused: true, stopGifs: false, replyVisibility: 'all', @@ -30,10 +31,12 @@ const defaultState = { muteWords: [], highlight: {}, interfaceLanguage: browserLocale, + hideScopeNotice: false, scopeCopy: undefined, // instance default subjectLineBehavior: undefined, // instance default alwaysShowSubjectInput: undefined, // instance default - postContentType: undefined // instance default + postContentType: undefined, // instance default + minimalScopesMode: undefined // instance default } const config = { diff --git a/src/modules/instance.js b/src/modules/instance.js index f778ac4d..fc4578ed 100644 --- a/src/modules/instance.js +++ b/src/modules/instance.js @@ -5,6 +5,7 @@ const defaultState = { // Stuff from static/config.json and apiConfig name: 'Pleroma FE', registrationOpen: true, + safeDM: true, textlimit: 5000, server: 'http://localhost:4040/', theme: 'pleroma-dark', @@ -15,7 +16,6 @@ const defaultState = { redirectRootNoLogin: '/main/all', redirectRootLogin: '/main/friends', showInstanceSpecificPanel: false, - scopeOptionsEnabled: true, formattingOptionsEnabled: false, alwaysShowSubjectInput: true, hideMutedPosts: false, @@ -27,11 +27,11 @@ const defaultState = { scopeCopy: true, subjectLineBehavior: 'email', postContentType: 'text/plain', - loginMethod: 'password', nsfwCensorImage: undefined, vapidPublicKey: undefined, noAttachmentLinks: false, showFeaturesPanel: true, + minimalScopesMode: false, // Nasty stuff pleromaBackend: true, diff --git a/src/modules/interface.js b/src/modules/interface.js index 956c9cb3..5b2762e5 100644 --- a/src/modules/interface.js +++ b/src/modules/interface.js @@ -11,7 +11,8 @@ const defaultState = { window.CSS.supports('filter', 'drop-shadow(0 0)') || window.CSS.supports('-webkit-filter', 'drop-shadow(0 0)') ) - } + }, + mobileLayout: false } const interfaceMod = { @@ -31,6 +32,9 @@ const interfaceMod = { }, setNotificationPermission (state, permission) { state.notificationPermission = permission + }, + setMobileLayout (state, value) { + state.mobileLayout = value } }, actions: { @@ -42,6 +46,9 @@ const interfaceMod = { }, setNotificationPermission ({ commit }, permission) { commit('setNotificationPermission', permission) + }, + setMobileLayout ({ commit }, value) { + commit('setMobileLayout', value) } } } diff --git a/src/modules/oauth.js b/src/modules/oauth.js index 144ff830..11cb10fe 100644 --- a/src/modules/oauth.js +++ b/src/modules/oauth.js @@ -1,16 +1,39 @@ const oauth = { state: { - client_id: false, - client_secret: false, - token: false + clientId: false, + clientSecret: false, + /* App token is authentication for app without any user, used mostly for + * MastoAPI's registration of new users, stored so that we can fall back to + * it on logout + */ + appToken: false, + /* User token is authentication for app with user, this is for every calls + * that need authorized user to be successful (i.e. posting, liking etc) + */ + userToken: false }, mutations: { - setClientData (state, data) { - state.client_id = data.client_id - state.client_secret = data.client_secret + setClientData (state, { clientId, clientSecret }) { + state.clientId = clientId + state.clientSecret = clientSecret + }, + setAppToken (state, token) { + state.appToken = token }, setToken (state, token) { - state.token = token + state.userToken = token + } + }, + getters: { + getToken: state => () => { + // state.token is userToken with older name, coming from persistent state + // added here for smoother transition, otherwise user will be logged out + return state.userToken || state.token || state.appToken + }, + getUserToken: state => () => { + // state.token is userToken with older name, coming from persistent state + // added here for smoother transition, otherwise user will be logged out + return state.userToken || state.token } } } diff --git a/src/modules/reports.js b/src/modules/reports.js new file mode 100644 index 00000000..904022f1 --- /dev/null +++ b/src/modules/reports.js @@ -0,0 +1,30 @@ +import filter from 'lodash/filter' + +const reports = { + state: { + userId: null, + statuses: [], + modalActivated: false + }, + mutations: { + openUserReportingModal (state, { userId, statuses }) { + state.userId = userId + state.statuses = statuses + state.modalActivated = true + }, + closeUserReportingModal (state) { + state.modalActivated = false + } + }, + actions: { + openUserReportingModal ({ rootState, commit }, userId) { + const statuses = filter(rootState.statuses.allStatuses, status => status.user.id === userId) + commit('openUserReportingModal', { userId, statuses }) + }, + closeUserReportingModal ({ commit }) { + commit('closeUserReportingModal') + } + } +} + +export default reports diff --git a/src/modules/statuses.js b/src/modules/statuses.js index 742eecba..e6ee5447 100644 --- a/src/modules/statuses.js +++ b/src/modules/statuses.js @@ -1,4 +1,4 @@ -import { remove, slice, each, find, maxBy, minBy, merge, first, last, isArray, omitBy } from 'lodash' +import { remove, slice, each, findIndex, find, maxBy, minBy, merge, first, last, isArray, omitBy } from 'lodash' import { set } from 'vue' import apiService from '../services/api/api.service.js' // import parse from '../services/status_parser/status_parser.js' @@ -20,20 +20,22 @@ const emptyTl = (userId = 0) => ({ flushMarker: 0 }) +const emptyNotifications = () => ({ + desktopNotificationSilence: true, + maxId: 0, + minId: Number.POSITIVE_INFINITY, + data: [], + idStore: {}, + loading: false, + error: false +}) + export const defaultState = () => ({ allStatuses: [], allStatusesObject: {}, + conversationsObject: {}, maxId: 0, - notifications: { - desktopNotificationSilence: true, - maxId: 0, - minId: Number.POSITIVE_INFINITY, - data: [], - idStore: {}, - loading: false, - error: false, - fetcherId: null - }, + notifications: emptyNotifications(), favorites: new Set(), error: false, timelines: { @@ -111,6 +113,39 @@ const sortTimeline = (timeline) => { return timeline } +// Add status to the global storages (arrays and objects maintaining statuses) except timelines +const addStatusToGlobalStorage = (state, data) => { + const result = mergeOrAdd(state.allStatuses, state.allStatusesObject, data) + if (result.new) { + // Add to conversation + const status = result.item + const conversationsObject = state.conversationsObject + const conversationId = status.statusnet_conversation_id + if (conversationsObject[conversationId]) { + conversationsObject[conversationId].push(status) + } else { + set(conversationsObject, conversationId, [status]) + } + } + return result +} + +// Remove status from the global storages (arrays and objects maintaining statuses) except timelines +const removeStatusFromGlobalStorage = (state, status) => { + remove(state.allStatuses, { id: status.id }) + + // TODO: Need to remove from allStatusesObject? + + // Remove possible notification + remove(state.notifications.data, ({action: {id}}) => id === status.id) + + // Remove from conversation + const conversationId = status.statusnet_conversation_id + if (state.conversationsObject[conversationId]) { + remove(state.conversationsObject[conversationId], { id: status.id }) + } +} + const addNewStatuses = (state, { statuses, showImmediately = false, timeline, user = {}, noIdUpdate = false, userId }) => { // Sanity check if (!isArray(statuses)) { @@ -118,12 +153,11 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us } const allStatuses = state.allStatuses - const allStatusesObject = state.allStatusesObject const timelineObject = state.timelines[timeline] const maxNew = statuses.length > 0 ? maxBy(statuses, 'id').id : 0 const minNew = statuses.length > 0 ? minBy(statuses, 'id').id : 0 - const newer = timeline && maxNew > timelineObject.maxId && statuses.length > 0 + const newer = timeline && (maxNew > timelineObject.maxId || timelineObject.maxId === 0) && statuses.length > 0 const older = timeline && (minNew < timelineObject.minId || timelineObject.minId === 0) && statuses.length > 0 if (!noIdUpdate && newer) { @@ -141,7 +175,7 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us } const addStatus = (data, showImmediately, addToTimeline = true) => { - const result = mergeOrAdd(allStatuses, allStatusesObject, data) + const result = addStatusToGlobalStorage(state, data) const status = result.item if (result.new) { @@ -235,16 +269,13 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us }, 'deletion': (deletion) => { const uri = deletion.uri - - // Remove possible notification const status = find(allStatuses, {uri}) if (!status) { return } - remove(state.notifications.data, ({action: {id}}) => id === status.id) + removeStatusFromGlobalStorage(state, status) - remove(allStatuses, { uri }) if (timeline) { remove(timelineObject.statuses, { uri }) remove(timelineObject.visibleStatuses, { uri }) @@ -271,12 +302,12 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us } } -const addNewNotifications = (state, { dispatch, notifications, older, visibleNotificationTypes }) => { - const allStatuses = state.allStatuses - const allStatusesObject = state.allStatusesObject +const addNewNotifications = (state, { dispatch, notifications, older, visibleNotificationTypes, rootGetters }) => { each(notifications, (notification) => { - notification.action = mergeOrAdd(allStatuses, allStatusesObject, notification.action).item - notification.status = notification.status && mergeOrAdd(allStatuses, allStatusesObject, notification.status).item + if (notification.type !== 'follow') { + notification.action = addStatusToGlobalStorage(state, notification.action).item + notification.status = notification.status && addStatusToGlobalStorage(state, notification.status).item + } // Only add a new notification if we don't have one for the same action if (!state.notifications.idStore.hasOwnProperty(notification.id)) { @@ -292,15 +323,32 @@ const addNewNotifications = (state, { dispatch, notifications, older, visibleNot if ('Notification' in window && window.Notification.permission === 'granted') { const notifObj = {} - const action = notification.action - const title = action.user.name - notifObj.icon = action.user.profile_image_url - notifObj.body = action.text // there's a problem that it doesn't put a space before links tho + const status = notification.status + const title = notification.from_profile.name + notifObj.icon = notification.from_profile.profile_image_url + let i18nString + switch (notification.type) { + case 'like': + i18nString = 'favorited_you' + break + case 'repeat': + i18nString = 'repeated_you' + break + case 'follow': + i18nString = 'followed_you' + break + } + + if (i18nString) { + notifObj.body = rootGetters.i18n.t('notifications.' + i18nString) + } else { + notifObj.body = notification.status.text + } // Shows first attached non-nsfw image, if any. Should add configuration for this somehow... - if (action.attachments && action.attachments.length > 0 && !action.nsfw && - action.attachments[0].mimetype.startsWith('image/')) { - notifObj.image = action.attachments[0].url + if (status && status.attachments && status.attachments.length > 0 && !status.nsfw && + status.attachments[0].mimetype.startsWith('image/')) { + notifObj.image = status.attachments[0].url } if (!notification.seen && !state.notifications.desktopNotificationSilence && visibleNotificationTypes.includes(notification.type)) { @@ -340,9 +388,6 @@ export const mutations = { oldTimeline.visibleStatusesObject = {} each(oldTimeline.visibleStatuses, (status) => { oldTimeline.visibleStatusesObject[status.id] = status }) }, - setNotificationFetcher (state, { fetcherId }) { - state.notifications.fetcherId = fetcherId - }, resetStatuses (state) { const emptyState = defaultState() Object.entries(emptyState).forEach(([key, value]) => { @@ -352,14 +397,36 @@ export const mutations = { clearTimeline (state, { timeline }) { state.timelines[timeline] = emptyTl(state.timelines[timeline].userId) }, + clearNotifications (state) { + state.notifications = emptyNotifications() + }, setFavorited (state, { status, value }) { const newStatus = state.allStatusesObject[status.id] + + if (newStatus.favorited !== value) { + if (value) { + newStatus.fave_num++ + } else { + newStatus.fave_num-- + } + } + newStatus.favorited = value }, - setFavoritedConfirm (state, { status }) { + setFavoritedConfirm (state, { status, user }) { const newStatus = state.allStatusesObject[status.id] newStatus.favorited = status.favorited newStatus.fave_num = status.fave_num + const index = findIndex(newStatus.favoritedBy, { id: user.id }) + if (index !== -1 && !newStatus.favorited) { + newStatus.favoritedBy.splice(index, 1) + } else if (index === -1 && newStatus.favorited) { + newStatus.favoritedBy.push(user) + } + }, + setPinned (state, status) { + const newStatus = state.allStatusesObject[status.id] + newStatus.pinned = status.pinned }, setRetweeted (state, { status, value }) { const newStatus = state.allStatusesObject[status.id] @@ -374,10 +441,28 @@ export const mutations = { newStatus.repeated = value }, + setRetweetedConfirm (state, { status, user }) { + const newStatus = state.allStatusesObject[status.id] + newStatus.repeated = status.repeated + newStatus.repeat_num = status.repeat_num + const index = findIndex(newStatus.rebloggedBy, { id: user.id }) + if (index !== -1 && !newStatus.repeated) { + newStatus.rebloggedBy.splice(index, 1) + } else if (index === -1 && newStatus.repeated) { + newStatus.rebloggedBy.push(user) + } + }, setDeleted (state, { status }) { const newStatus = state.allStatusesObject[status.id] newStatus.deleted = true }, + setManyDeleted (state, condition) { + Object.values(state.allStatusesObject).forEach(status => { + if (condition(status)) { + status.deleted = true + } + }) + }, setLoading (state, { timeline, value }) { state.timelines[timeline].loading = value }, @@ -404,6 +489,11 @@ export const mutations = { }, queueFlush (state, { timeline, id }) { state.timelines[timeline].flushMarker = id + }, + addFavsAndRepeats (state, { id, favoritedByUsers, rebloggedByUsers }) { + const newStatus = state.allStatusesObject[id] + newStatus.favoritedBy = favoritedByUsers.filter(_ => _) + newStatus.rebloggedBy = rebloggedByUsers.filter(_ => _) } } @@ -413,8 +503,8 @@ const statuses = { addNewStatuses ({ rootState, commit }, { statuses, showImmediately = false, timeline = false, noIdUpdate = false, userId }) { commit('addNewStatuses', { statuses, showImmediately, timeline, noIdUpdate, user: rootState.users.currentUser, userId }) }, - addNewNotifications ({ rootState, commit, dispatch }, { notifications, older }) { - commit('addNewNotifications', { visibleNotificationTypes: visibleNotificationTypes(rootState), dispatch, notifications, older }) + addNewNotifications ({ rootState, commit, dispatch, rootGetters }, { notifications, older }) { + commit('addNewNotifications', { visibleNotificationTypes: visibleNotificationTypes(rootState), dispatch, notifications, older, rootGetters }) }, setError ({ rootState, commit }, { value }) { commit('setError', { value }) @@ -428,40 +518,48 @@ const statuses = { setNotificationsSilence ({ rootState, commit }, { value }) { commit('setNotificationsSilence', { value }) }, - stopFetchingNotifications ({ rootState, commit }) { - if (rootState.statuses.notifications.fetcherId) { - window.clearInterval(rootState.statuses.notifications.fetcherId) - } - commit('setNotificationFetcher', { fetcherId: null }) - }, deleteStatus ({ rootState, commit }, status) { commit('setDeleted', { status }) apiService.deleteStatus({ id: status.id, credentials: rootState.users.currentUser.credentials }) }, + markStatusesAsDeleted ({ commit }, condition) { + commit('setManyDeleted', condition) + }, favorite ({ rootState, commit }, status) { // Optimistic favoriting... commit('setFavorited', { status, value: true }) - apiService.favorite({ id: status.id, credentials: rootState.users.currentUser.credentials }) - .then(status => { - commit('setFavoritedConfirm', { status }) - }) + rootState.api.backendInteractor.favorite(status.id) + .then(status => commit('setFavoritedConfirm', { status, user: rootState.users.currentUser })) }, unfavorite ({ rootState, commit }, status) { - // Optimistic favoriting... + // Optimistic unfavoriting... commit('setFavorited', { status, value: false }) - apiService.unfavorite({ id: status.id, credentials: rootState.users.currentUser.credentials }) - .then(status => { - commit('setFavoritedConfirm', { status }) - }) + rootState.api.backendInteractor.unfavorite(status.id) + .then(status => commit('setFavoritedConfirm', { status, user: rootState.users.currentUser })) + }, + fetchPinnedStatuses ({ rootState, dispatch }, userId) { + rootState.api.backendInteractor.fetchPinnedStatuses(userId) + .then(statuses => dispatch('addNewStatuses', { statuses, timeline: 'user', userId, showImmediately: true })) + }, + pinStatus ({ rootState, commit }, statusId) { + return rootState.api.backendInteractor.pinOwnStatus(statusId) + .then((status) => commit('setPinned', status)) + }, + unpinStatus ({ rootState, commit }, statusId) { + rootState.api.backendInteractor.unpinOwnStatus(statusId) + .then((status) => commit('setPinned', status)) }, retweet ({ rootState, commit }, status) { // Optimistic retweeting... commit('setRetweeted', { status, value: true }) - apiService.retweet({ id: status.id, credentials: rootState.users.currentUser.credentials }) + rootState.api.backendInteractor.retweet(status.id) + .then(status => commit('setRetweetedConfirm', { status: status.retweeted_status, user: rootState.users.currentUser })) }, unretweet ({ rootState, commit }, status) { + // Optimistic unretweeting... commit('setRetweeted', { status, value: false }) - apiService.unretweet({ id: status.id, credentials: rootState.users.currentUser.credentials }) + rootState.api.backendInteractor.unretweet(status.id) + .then(status => commit('setRetweetedConfirm', { status, user: rootState.users.currentUser })) }, queueFlush ({ rootState, commit }, { timeline, id }) { commit('queueFlush', { timeline, id }) @@ -472,6 +570,14 @@ const statuses = { id: rootState.statuses.notifications.maxId, credentials: rootState.users.currentUser.credentials }) + }, + fetchFavsAndRepeats ({ rootState, commit }, id) { + Promise.all([ + rootState.api.backendInteractor.fetchFavoritedByUsers(id), + rootState.api.backendInteractor.fetchRebloggedByUsers(id) + ]).then(([favoritedByUsers, rebloggedByUsers]) => + commit('addFavsAndRepeats', { id, favoritedByUsers, rebloggedByUsers }) + ) } }, mutations diff --git a/src/modules/users.js b/src/modules/users.js index 1a507d31..739b8b92 100644 --- a/src/modules/users.js +++ b/src/modules/users.js @@ -1,8 +1,8 @@ import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js' -import { compact, map, each, merge, find, last } from 'lodash' +import userSearchApi from '../services/new_api/user_search.js' +import { compact, map, each, merge, last, concat, uniq } from 'lodash' import { set } from 'vue' import { registerPushNotifications, unregisterPushNotifications } from '../services/push/push.js' -import oauthApi from '../services/new_api/oauth' import { humanizeErrors } from './errors' // TODO: Unify with mergeOrAdd in statuses.js @@ -32,11 +32,62 @@ const getNotificationPermission = () => { return Promise.resolve(Notification.permission) } +const blockUser = (store, id) => { + return store.rootState.api.backendInteractor.blockUser(id) + .then((relationship) => { + store.commit('updateUserRelationship', [relationship]) + store.commit('addBlockId', id) + store.commit('removeStatus', { timeline: 'friends', userId: id }) + store.commit('removeStatus', { timeline: 'public', userId: id }) + store.commit('removeStatus', { timeline: 'publicAndExternal', userId: id }) + }) +} + +const unblockUser = (store, id) => { + return store.rootState.api.backendInteractor.unblockUser(id) + .then((relationship) => store.commit('updateUserRelationship', [relationship])) +} + +const muteUser = (store, id) => { + return store.rootState.api.backendInteractor.muteUser(id) + .then((relationship) => { + store.commit('updateUserRelationship', [relationship]) + store.commit('addMuteId', id) + }) +} + +const unmuteUser = (store, id) => { + return store.rootState.api.backendInteractor.unmuteUser(id) + .then((relationship) => store.commit('updateUserRelationship', [relationship])) +} + export const mutations = { setMuted (state, { user: { id }, muted }) { const user = state.usersObject[id] set(user, 'muted', muted) }, + tagUser (state, { user: { id }, tag }) { + const user = state.usersObject[id] + const tags = user.tags || [] + const newTags = tags.concat([tag]) + set(user, 'tags', newTags) + }, + untagUser (state, { user: { id }, tag }) { + const user = state.usersObject[id] + const tags = user.tags || [] + const newTags = tags.filter(t => t !== tag) + set(user, 'tags', newTags) + }, + updateRight (state, { user: { id }, right, value }) { + const user = state.usersObject[id] + let newRights = user.rights + newRights[right] = value + set(user, 'rights', newRights) + }, + updateActivationStatus (state, { user: { id }, status }) { + const user = state.usersObject[id] + set(user, 'deactivated', !status) + }, setCurrentUser (state, user) { state.lastLoginName = user.screen_name state.currentUser = merge(state.currentUser || {}, user) @@ -51,42 +102,27 @@ export const mutations = { endLogin (state) { state.loggingIn = false }, - // TODO Clean after ourselves? - addFriends (state, { id, friends }) { + saveFriendIds (state, { id, friendIds }) { const user = state.usersObject[id] - each(friends, friend => { - if (!find(user.friends, { id: friend.id })) { - user.friends.push(friend) - } - }) - user.lastFriendId = last(friends).id + user.friendIds = uniq(concat(user.friendIds, friendIds)) }, - addFollowers (state, { id, followers }) { + saveFollowerIds (state, { id, followerIds }) { const user = state.usersObject[id] - each(followers, follower => { - if (!find(user.followers, { id: follower.id })) { - user.followers.push(follower) - } - }) - user.lastFollowerId = last(followers).id + user.followerIds = uniq(concat(user.followerIds, followerIds)) }, // Because frontend doesn't have a reason to keep these stuff in memory // outside of viewing someones user profile. clearFriends (state, userId) { const user = state.usersObject[userId] - if (!user) { - return + if (user) { + set(user, 'friendIds', []) } - user.friends = [] - user.lastFriendId = null }, clearFollowers (state, userId) { const user = state.usersObject[userId] - if (!user) { - return + if (user) { + set(user, 'followerIds', []) } - user.followers = [] - user.lastFollowerId = null }, addNewUsers (state, users) { each(users, (user) => mergeOrAdd(state.users, state.usersObject, user)) @@ -110,6 +146,11 @@ export const mutations = { saveBlockIds (state, blockIds) { state.currentUser.blockIds = blockIds }, + addBlockId (state, blockId) { + if (state.currentUser.blockIds.indexOf(blockId) === -1) { + state.currentUser.blockIds.push(blockId) + } + }, updateMutes (state, mutedUsers) { // Reset muted of all fetched users each(state.users, (user) => { user.muted = false }) @@ -118,12 +159,28 @@ export const mutations = { saveMuteIds (state, muteIds) { state.currentUser.muteIds = muteIds }, + addMuteId (state, muteId) { + if (state.currentUser.muteIds.indexOf(muteId) === -1) { + state.currentUser.muteIds.push(muteId) + } + }, + setPinned (state, status) { + const user = state.usersObject[status.user.id] + const index = user.pinnedStatuseIds.indexOf(status.id) + if (status.pinned && index === -1) { + user.pinnedStatuseIds.push(status.id) + } else if (!status.pinned && index !== -1) { + user.pinnedStatuseIds.splice(index, 1) + } + }, setUserForStatus (state, status) { status.user = state.usersObject[status.user.id] }, setUserForNotification (state, notification) { - notification.action.user = state.usersObject[notification.action.user.id] - notification.from_profile = state.usersObject[notification.action.user.id] + if (notification.type !== 'follow') { + notification.action.user = state.usersObject[notification.action.user.id] + } + notification.from_profile = state.usersObject[notification.from_profile.id] }, setColor (state, { user: { id }, highlighted }) { const user = state.usersObject[id] @@ -176,8 +233,10 @@ const users = { }) }, fetchUserRelationship (store, id) { - return store.rootState.api.backendInteractor.fetchUserRelationship({ id }) - .then((relationships) => store.commit('updateUserRelationship', relationships)) + if (store.state.currentUser) { + store.rootState.api.backendInteractor.fetchUserRelationship({ id }) + .then((relationships) => store.commit('updateUserRelationship', relationships)) + } }, fetchBlocks (store) { return store.rootState.api.backendInteractor.fetchBlocks() @@ -187,18 +246,17 @@ const users = { return blocks }) }, - blockUser (store, userId) { - return store.rootState.api.backendInteractor.blockUser(userId) - .then((relationship) => { - store.commit('updateUserRelationship', [relationship]) - store.commit('removeStatus', { timeline: 'friends', userId }) - store.commit('removeStatus', { timeline: 'public', userId }) - store.commit('removeStatus', { timeline: 'publicAndExternal', userId }) - }) + blockUser (store, id) { + return blockUser(store, id) }, unblockUser (store, id) { - return store.rootState.api.backendInteractor.unblockUser(id) - .then((relationship) => store.commit('updateUserRelationship', [relationship])) + return unblockUser(store, id) + }, + blockUsers (store, ids = []) { + return Promise.all(ids.map(id => blockUser(store, id))) + }, + unblockUsers (store, ids = []) { + return Promise.all(ids.map(id => unblockUser(store, id))) }, fetchMutes (store) { return store.rootState.api.backendInteractor.fetchMutes() @@ -209,32 +267,34 @@ const users = { }) }, muteUser (store, id) { - return store.rootState.api.backendInteractor.muteUser(id) - .then((relationship) => store.commit('updateUserRelationship', [relationship])) + return muteUser(store, id) }, unmuteUser (store, id) { - return store.rootState.api.backendInteractor.unmuteUser(id) - .then((relationship) => store.commit('updateUserRelationship', [relationship])) + return unmuteUser(store, id) }, - addFriends ({ rootState, commit }, fetchBy) { - return new Promise((resolve, reject) => { - const user = rootState.users.usersObject[fetchBy] - const maxId = user.lastFriendId - rootState.api.backendInteractor.fetchFriends({ id: user.id, maxId }) - .then((friends) => { - commit('addFriends', { id: user.id, friends }) - resolve(friends) - }).catch(() => { - reject() - }) - }) + muteUsers (store, ids = []) { + return Promise.all(ids.map(id => muteUser(store, id))) + }, + unmuteUsers (store, ids = []) { + return Promise.all(ids.map(id => unmuteUser(store, id))) }, - addFollowers ({ rootState, commit }, fetchBy) { - const user = rootState.users.usersObject[fetchBy] - const maxId = user.lastFollowerId - return rootState.api.backendInteractor.fetchFollowers({ id: user.id, maxId }) + fetchFriends ({ rootState, commit }, id) { + const user = rootState.users.usersObject[id] + const maxId = last(user.friendIds) + return rootState.api.backendInteractor.fetchFriends({ id, maxId }) + .then((friends) => { + commit('addNewUsers', friends) + commit('saveFriendIds', { id, friendIds: map(friends, 'id') }) + return friends + }) + }, + fetchFollowers ({ rootState, commit }, id) { + const user = rootState.users.usersObject[id] + const maxId = last(user.followerIds) + return rootState.api.backendInteractor.fetchFollowers({ id, maxId }) .then((followers) => { - commit('addFollowers', { id: user.id, followers }) + commit('addNewUsers', followers) + commit('saveFollowerIds', { id, followerIds: map(followers, 'id') }) return followers }) }, @@ -257,19 +317,26 @@ const users = { unregisterPushNotifications(token) }, + addNewUsers ({ commit }, users) { + commit('addNewUsers', users) + }, addNewStatuses (store, { statuses }) { const users = map(statuses, 'user') const retweetedUsers = compact(map(statuses, 'retweeted_status.user')) store.commit('addNewUsers', users) store.commit('addNewUsers', retweetedUsers) - // Reconnect users to statuses each(statuses, (status) => { + // Reconnect users to statuses store.commit('setUserForStatus', status) + // Set pinned statuses to user + store.commit('setPinned', status) }) - // Reconnect users to retweets each(compact(map(statuses, 'retweeted_status')), (status) => { + // Reconnect users to retweets store.commit('setUserForStatus', status) + // Set pinned retweets to user + store.commit('setPinned', status) }) }, addNewNotifications (store, { notifications }) { @@ -287,36 +354,34 @@ const users = { store.commit('setUserForNotification', notification) }) }, + searchUsers (store, query) { + // TODO: Move userSearch api into api.service + return userSearchApi.search({query, store: { state: store.rootState }}) + .then((users) => { + store.commit('addNewUsers', users) + return users + }) + }, async signUp (store, userInfo) { store.commit('signUpPending') let rootState = store.rootState - let response = await rootState.api.backendInteractor.register(userInfo) - if (response.ok) { - const data = { - oauth: rootState.oauth, - instance: rootState.instance.server - } - let app = await oauthApi.getOrCreateApp(data) - let result = await oauthApi.getTokenWithCredentials({ - app, - instance: data.instance, - username: userInfo.username, - password: userInfo.password - }) + try { + let data = await rootState.api.backendInteractor.register(userInfo) store.commit('signUpSuccess') - store.commit('setToken', result.access_token) - store.dispatch('loginUser', result.access_token) - } else { - const data = await response.json() - let errors = JSON.parse(data.error) + store.commit('setToken', data.access_token) + store.dispatch('loginUser', data.access_token) + } catch (e) { + let errors = e.message // replace ap_id with username - if (errors.ap_id) { - errors.username = errors.ap_id - delete errors.ap_id + if (typeof errors === 'object') { + if (errors.ap_id) { + errors.username = errors.ap_id + delete errors.ap_id + } + errors = humanizeErrors(errors) } - errors = humanizeErrors(errors) store.commit('signUpFailure', errors) throw Error(errors) } @@ -330,8 +395,9 @@ const users = { store.dispatch('disconnectFromChat') store.commit('setToken', false) store.dispatch('stopFetching', 'friends') - store.commit('setBackendInteractor', backendInteractorService()) - store.dispatch('stopFetchingNotifications') + store.commit('setBackendInteractor', backendInteractorService(store.getters.getToken())) + store.dispatch('stopFetching', 'notifications') + store.commit('clearNotifications') store.commit('resetStatuses') }, loginUser (store, accessToken) { @@ -363,7 +429,10 @@ const users = { } // Start getting fresh posts. - store.dispatch('startFetching', { timeline: 'friends' }) + store.dispatch('startFetchingTimeline', { timeline: 'friends' }) + + // Start fetching notifications + store.dispatch('startFetchingNotifications') // Get user mutes store.dispatch('fetchMutes') diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js index 030c2f5e..05d968f7 100644 --- a/src/services/api/api.service.js +++ b/src/services/api/api.service.js @@ -1,24 +1,33 @@ /* eslint-env browser */ -const LOGIN_URL = '/api/account/verify_credentials.json' -const ALL_FOLLOWING_URL = '/api/qvitter/allfollowing' -const MENTIONS_URL = '/api/statuses/mentions.json' -const REGISTRATION_URL = '/api/account/register.json' -const AVATAR_UPDATE_URL = '/api/qvitter/update_avatar.json' const BG_UPDATE_URL = '/api/qvitter/update_background_image.json' -const BANNER_UPDATE_URL = '/api/account/update_profile_banner.json' -const PROFILE_UPDATE_URL = '/api/account/update_profile.json' const EXTERNAL_PROFILE_URL = '/api/externalprofile/show.json' -const QVITTER_USER_NOTIFICATIONS_URL = '/api/qvitter/statuses/notifications.json' const QVITTER_USER_NOTIFICATIONS_READ_URL = '/api/qvitter/statuses/notifications/read.json' +const BLOCKS_IMPORT_URL = '/api/pleroma/blocks_import' const FOLLOW_IMPORT_URL = '/api/pleroma/follow_import' const DELETE_ACCOUNT_URL = '/api/pleroma/delete_account' const CHANGE_PASSWORD_URL = '/api/pleroma/change_password' const FOLLOW_REQUESTS_URL = '/api/pleroma/friend_requests' const APPROVE_USER_URL = '/api/pleroma/friendships/approve' const DENY_USER_URL = '/api/pleroma/friendships/deny' +const TAG_USER_URL = '/api/pleroma/admin/users/tag' +const PERMISSION_GROUP_URL = (screenName, right) => `/api/pleroma/admin/users/${screenName}/permission_group/${right}` +const ACTIVATION_STATUS_URL = screenName => `/api/pleroma/admin/users/${screenName}/activation_status` +const ADMIN_USERS_URL = '/api/pleroma/admin/users' const SUGGESTIONS_URL = '/api/v1/suggestions' +const NOTIFICATION_SETTINGS_URL = '/api/pleroma/notification_settings' +const MFA_SETTINGS_URL = '/api/pleroma/profile/mfa' +const MFA_BACKUP_CODES_URL = '/api/pleroma/profile/mfa/backup_codes' + +const MFA_SETUP_OTP_URL = '/api/pleroma/profile/mfa/setup/totp' +const MFA_CONFIRM_OTP_URL = '/api/pleroma/profile/mfa/confirm/totp' +const MFA_DISABLE_OTP_URL = '/api/pleroma/profile/mfa/totp' + +const MASTODON_LOGIN_URL = '/api/v1/accounts/verify_credentials' +const MASTODON_REGISTRATION_URL = '/api/v1/accounts' +const GET_BACKGROUND_HACK = '/api/account/verify_credentials.json' const MASTODON_USER_FAVORITES_TIMELINE_URL = '/api/v1/favourites' +const MASTODON_USER_NOTIFICATIONS_URL = '/api/v1/notifications' const MASTODON_FAVORITE_URL = id => `/api/v1/statuses/${id}/favourite` const MASTODON_UNFAVORITE_URL = id => `/api/v1/statuses/${id}/unfavourite` const MASTODON_RETWEET_URL = id => `/api/v1/statuses/${id}/reblog` @@ -45,8 +54,14 @@ const MASTODON_MUTE_USER_URL = id => `/api/v1/accounts/${id}/mute` const MASTODON_UNMUTE_USER_URL = id => `/api/v1/accounts/${id}/unmute` const MASTODON_POST_STATUS_URL = '/api/v1/statuses' const MASTODON_MEDIA_UPLOAD_URL = '/api/v1/media' - -import { each, map } from 'lodash' +const MASTODON_STATUS_FAVORITEDBY_URL = id => `/api/v1/statuses/${id}/favourited_by` +const MASTODON_STATUS_REBLOGGEDBY_URL = id => `/api/v1/statuses/${id}/reblogged_by` +const MASTODON_PROFILE_UPDATE_URL = '/api/v1/accounts/update_credentials' +const MASTODON_REPORT_USER_URL = '/api/v1/reports' +const MASTODON_PIN_OWN_STATUS = id => `/api/v1/statuses/${id}/pin` +const MASTODON_UNPIN_OWN_STATUS = id => `/api/v1/statuses/${id}/unpin` + +import { each, map, concat, last } from 'lodash' import { parseStatus, parseUser, parseNotification, parseAttachment } from '../entity_normalizer/entity_normalizer.service.js' import 'whatwg-fetch' import { StatusCodeError } from '../errors/errors' @@ -61,7 +76,24 @@ let fetch = (url, options) => { return oldfetch(fullUrl, options) } -const promisedRequest = (url, options) => { +const promisedRequest = ({ method, url, payload, credentials, headers = {} }) => { + const options = { + method, + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + ...headers + } + } + if (payload) { + options.body = JSON.stringify(payload) + } + if (credentials) { + options.headers = { + ...options.headers, + ...authHeaders(credentials) + } + } return fetch(url, options) .then((response) => { return new Promise((resolve, reject) => response.json() @@ -74,28 +106,31 @@ const promisedRequest = (url, options) => { }) } -// Params -// cropH -// cropW -// cropX -// cropY -// img (base 64 encodend data url) -const updateAvatar = ({credentials, params}) => { - let url = AVATAR_UPDATE_URL - +const updateNotificationSettings = ({credentials, settings}) => { const form = new FormData() - each(params, (value, key) => { - if (value) { - form.append(key, value) - } + each(settings, (value, key) => { + form.append(key, value) }) - return fetch(url, { + return fetch(NOTIFICATION_SETTINGS_URL, { headers: authHeaders(credentials), - method: 'POST', + method: 'PUT', body: form - }).then((data) => data.json()) + }) + .then((data) => data.json()) +} + +const updateAvatar = ({credentials, avatar}) => { + const form = new FormData() + form.append('avatar', avatar) + return fetch(MASTODON_PROFILE_UPDATE_URL, { + headers: authHeaders(credentials), + method: 'PATCH', + body: form + }) + .then((data) => data.json()) + .then((data) => parseUser(data)) } const updateBg = ({credentials, params}) => { @@ -116,52 +151,26 @@ const updateBg = ({credentials, params}) => { }).then((data) => data.json()) } -// Params -// height -// width -// offset_left -// offset_top -// banner (base 64 encodend data url) -const updateBanner = ({credentials, params}) => { - let url = BANNER_UPDATE_URL - +const updateBanner = ({credentials, banner}) => { const form = new FormData() - - each(params, (value, key) => { - if (value) { - form.append(key, value) - } - }) - - return fetch(url, { + form.append('header', banner) + return fetch(MASTODON_PROFILE_UPDATE_URL, { headers: authHeaders(credentials), - method: 'POST', + method: 'PATCH', body: form - }).then((data) => data.json()) + }) + .then((data) => data.json()) + .then((data) => parseUser(data)) } -// Params -// name -// url -// location -// description const updateProfile = ({credentials, params}) => { - // Always include these fields, because they might be empty or false - const fields = ['description', 'locked', 'no_rich_text', 'hide_follows', 'hide_followers', 'show_role'] - let url = PROFILE_UPDATE_URL - - const form = new FormData() - - each(params, (value, key) => { - if (fields.includes(key) || value) { - form.append(key, value) - } + return promisedRequest({ + url: MASTODON_PROFILE_UPDATE_URL, + method: 'PATCH', + payload: params, + credentials }) - return fetch(url, { - headers: authHeaders(credentials), - method: 'POST', - body: form - }).then((data) => data.json()) + .then((data) => parseUser(data)) } // Params needed: @@ -176,19 +185,29 @@ const updateProfile = ({credentials, params}) => { // homepage // location // token -const register = (params) => { - const form = new FormData() - - each(params, (value, key) => { - if (value) { - form.append(key, value) - } - }) - - return fetch(REGISTRATION_URL, { +const register = ({ params, credentials }) => { + const { nickname, ...rest } = params + return fetch(MASTODON_REGISTRATION_URL, { method: 'POST', - body: form + headers: { + ...authHeaders(credentials), + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + nickname, + locale: 'en_US', + agreement: true, + ...rest + }) }) + .then((response) => [response.ok, response]) + .then(([ok, response]) => { + if (ok) { + return response.json() + } else { + return response.json().then((error) => { throw new Error(error) }) + } + }) } const getCaptcha = () => fetch('/api/pleroma/captcha').then(resp => resp.json()) @@ -225,6 +244,16 @@ const unfollowUser = ({id, credentials}) => { }).then((data) => data.json()) } +const pinOwnStatus = ({ id, credentials }) => { + return promisedRequest({ url: MASTODON_PIN_OWN_STATUS(id), credentials, method: 'POST' }) + .then((data) => parseStatus(data)) +} + +const unpinOwnStatus = ({ id, credentials }) => { + return promisedRequest({ url: MASTODON_UNPIN_OWN_STATUS(id), credentials, method: 'POST' }) + .then((data) => parseStatus(data)) +} + const blockUser = ({id, credentials}) => { return fetch(MASTODON_BLOCK_USER_URL(id), { headers: authHeaders(credentials), @@ -257,7 +286,7 @@ const denyUser = ({id, credentials}) => { const fetchUser = ({id, credentials}) => { let url = `${MASTODON_USER_URL}/${id}` - return promisedRequest(url, { headers: authHeaders(credentials) }) + return promisedRequest({ url, credentials }) .then((data) => parseUser(data)) } @@ -290,10 +319,23 @@ const fetchFriends = ({id, maxId, sinceId, limit = 20, credentials}) => { } const exportFriends = ({id, credentials}) => { - let url = MASTODON_FOLLOWING_URL(id) + `?all=true` - return fetch(url, { headers: authHeaders(credentials) }) - .then((data) => data.json()) - .then((data) => data.map(parseUser)) + return new Promise(async (resolve, reject) => { + try { + let friends = [] + let more = true + while (more) { + const maxId = friends.length > 0 ? last(friends).id : undefined + const users = await fetchFriends({id, maxId, credentials}) + friends = concat(friends, users) + if (users.length === 0) { + more = false + } + } + resolve(friends) + } catch (err) { + reject(err) + } + }) } const fetchFollowers = ({id, maxId, sinceId, limit = 20, credentials}) => { @@ -310,13 +352,6 @@ const fetchFollowers = ({id, maxId, sinceId, limit = 20, credentials}) => { .then((data) => data.map(parseUser)) } -const fetchAllFollowing = ({username, credentials}) => { - const url = `${ALL_FOLLOWING_URL}/${username}.json` - return fetch(url, { headers: authHeaders(credentials) }) - .then((data) => data.json()) - .then((data) => data.map(parseUser)) -} - const fetchFollowRequests = ({credentials}) => { const url = FOLLOW_REQUESTS_URL return fetch(url, { headers: authHeaders(credentials) }) @@ -352,13 +387,92 @@ const fetchStatus = ({id, credentials}) => { .then((data) => parseStatus(data)) } +const tagUser = ({tag, credentials, ...options}) => { + const screenName = options.screen_name + const form = { + nicknames: [screenName], + tags: [tag] + } + + const headers = authHeaders(credentials) + headers['Content-Type'] = 'application/json' + + return fetch(TAG_USER_URL, { + method: 'PUT', + headers: headers, + body: JSON.stringify(form) + }) +} + +const untagUser = ({tag, credentials, ...options}) => { + const screenName = options.screen_name + const body = { + nicknames: [screenName], + tags: [tag] + } + + const headers = authHeaders(credentials) + headers['Content-Type'] = 'application/json' + + return fetch(TAG_USER_URL, { + method: 'DELETE', + headers: headers, + body: JSON.stringify(body) + }) +} + +const addRight = ({right, credentials, ...user}) => { + const screenName = user.screen_name + + return fetch(PERMISSION_GROUP_URL(screenName, right), { + method: 'POST', + headers: authHeaders(credentials), + body: {} + }) +} + +const deleteRight = ({right, credentials, ...user}) => { + const screenName = user.screen_name + + return fetch(PERMISSION_GROUP_URL(screenName, right), { + method: 'DELETE', + headers: authHeaders(credentials), + body: {} + }) +} + +const setActivationStatus = ({status, credentials, ...user}) => { + const screenName = user.screen_name + const body = { + status: status + } + + const headers = authHeaders(credentials) + headers['Content-Type'] = 'application/json' + + return fetch(ACTIVATION_STATUS_URL(screenName), { + method: 'PUT', + headers: headers, + body: JSON.stringify(body) + }) +} + +const deleteUser = ({credentials, ...user}) => { + const screenName = user.screen_name + const headers = authHeaders(credentials) + + return fetch(`${ADMIN_USERS_URL}?nickname=${screenName}`, { + method: 'DELETE', + headers: headers + }) +} + const fetchTimeline = ({timeline, credentials, since = false, until = false, userId = false, tag = false, withMuted = false}) => { const timelineUrls = { public: MASTODON_PUBLIC_TIMELINE, friends: MASTODON_USER_HOME_TIMELINE_URL, - mentions: MENTIONS_URL, dms: MASTODON_DIRECT_MESSAGES_TIMELINE_URL, - notifications: QVITTER_USER_NOTIFICATIONS_URL, + notifications: MASTODON_USER_NOTIFICATIONS_URL, 'publicAndExternal': MASTODON_PUBLIC_TIMELINE, user: MASTODON_USER_TIMELINE_URL, media: MASTODON_USER_TIMELINE_URL, @@ -410,9 +524,14 @@ const fetchTimeline = ({timeline, credentials, since = false, until = false, use .then((data) => data.map(isNotifications ? parseNotification : parseStatus)) } +const fetchPinnedStatuses = ({ id, credentials }) => { + const url = MASTODON_USER_TIMELINE_URL(id) + '?pinned=true' + return promisedRequest({ url, credentials }) + .then((data) => data.map(parseStatus)) +} + const verifyCredentials = (user) => { - return fetch(LOGIN_URL, { - method: 'POST', + return fetch(MASTODON_LOGIN_URL, { headers: authHeaders(user) }) .then((response) => { @@ -425,65 +544,45 @@ const verifyCredentials = (user) => { } }) .then((data) => data.error ? data : parseUser(data)) + .then((mastoUser) => { + // REMOVE WHEN BE SUPPORTS background_image + return fetch(GET_BACKGROUND_HACK, { + method: 'POST', + headers: authHeaders(user) + }) + .then((response) => { + if (response.ok) { + return response.json() + } else { + return {} + } + }) + /* eslint-disable camelcase */ + .then(({ background_image }) => ({ + ...mastoUser, + background_image + })) + /* eslint-enable camelcase */ + }) } const favorite = ({ id, credentials }) => { - return fetch(MASTODON_FAVORITE_URL(id), { - headers: authHeaders(credentials), - method: 'POST' - }) - .then(response => { - if (response.ok) { - return response.json() - } else { - throw new Error('Error favoriting post') - } - }) + return promisedRequest({ url: MASTODON_FAVORITE_URL(id), method: 'POST', credentials }) .then((data) => parseStatus(data)) } const unfavorite = ({ id, credentials }) => { - return fetch(MASTODON_UNFAVORITE_URL(id), { - headers: authHeaders(credentials), - method: 'POST' - }) - .then(response => { - if (response.ok) { - return response.json() - } else { - throw new Error('Error removing favorite') - } - }) + return promisedRequest({ url: MASTODON_UNFAVORITE_URL(id), method: 'POST', credentials }) .then((data) => parseStatus(data)) } const retweet = ({ id, credentials }) => { - return fetch(MASTODON_RETWEET_URL(id), { - headers: authHeaders(credentials), - method: 'POST' - }) - .then(response => { - if (response.ok) { - return response.json() - } else { - throw new Error('Error repeating post') - } - }) + return promisedRequest({ url: MASTODON_RETWEET_URL(id), method: 'POST', credentials }) .then((data) => parseStatus(data)) } const unretweet = ({ id, credentials }) => { - return fetch(MASTODON_UNRETWEET_URL(id), { - headers: authHeaders(credentials), - method: 'POST' - }) - .then(response => { - if (response.ok) { - return response.json() - } else { - throw new Error('Error removing repeat') - } - }) + return promisedRequest({ url: MASTODON_UNRETWEET_URL(id), method: 'POST', credentials }) .then((data) => parseStatus(data)) } @@ -537,9 +636,22 @@ const uploadMedia = ({formData, credentials}) => { .then((data) => parseAttachment(data)) } -const followImport = ({params, credentials}) => { +const importBlocks = ({file, credentials}) => { + const formData = new FormData() + formData.append('list', file) + return fetch(BLOCKS_IMPORT_URL, { + body: formData, + method: 'POST', + headers: authHeaders(credentials) + }) + .then((response) => response.ok) +} + +const importFollows = ({file, credentials}) => { + const formData = new FormData() + formData.append('list', file) return fetch(FOLLOW_IMPORT_URL, { - body: params, + body: formData, method: 'POST', headers: authHeaders(credentials) }) @@ -574,27 +686,66 @@ const changePassword = ({credentials, password, newPassword, newPasswordConfirma .then((response) => response.json()) } +const settingsMFA = ({credentials}) => { + return fetch(MFA_SETTINGS_URL, { + headers: authHeaders(credentials), + method: 'GET' + }).then((data) => data.json()) +} + +const mfaDisableOTP = ({credentials, password}) => { + const form = new FormData() + + form.append('password', password) + + return fetch(MFA_DISABLE_OTP_URL, { + body: form, + method: 'DELETE', + headers: authHeaders(credentials) + }) + .then((response) => response.json()) +} + +const mfaConfirmOTP = ({credentials, password, token}) => { + const form = new FormData() + + form.append('password', password) + form.append('code', token) + + return fetch(MFA_CONFIRM_OTP_URL, { + body: form, + headers: authHeaders(credentials), + method: 'POST' + }).then((data) => data.json()) +} +const mfaSetupOTP = ({credentials}) => { + return fetch(MFA_SETUP_OTP_URL, { + headers: authHeaders(credentials), + method: 'GET' + }).then((data) => data.json()) +} +const generateMfaBackupCodes = ({credentials}) => { + return fetch(MFA_BACKUP_CODES_URL, { + headers: authHeaders(credentials), + method: 'GET' + }).then((data) => data.json()) +} + const fetchMutes = ({credentials}) => { - return promisedRequest(MASTODON_USER_MUTES_URL, { headers: authHeaders(credentials) }) + return promisedRequest({ url: MASTODON_USER_MUTES_URL, credentials }) .then((users) => users.map(parseUser)) } const muteUser = ({id, credentials}) => { - return promisedRequest(MASTODON_MUTE_USER_URL(id), { - headers: authHeaders(credentials), - method: 'POST' - }) + return promisedRequest({ url: MASTODON_MUTE_USER_URL(id), credentials, method: 'POST' }) } const unmuteUser = ({id, credentials}) => { - return promisedRequest(MASTODON_UNMUTE_USER_URL(id), { - headers: authHeaders(credentials), - method: 'POST' - }) + return promisedRequest({ url: MASTODON_UNMUTE_USER_URL(id), credentials, method: 'POST' }) } const fetchBlocks = ({credentials}) => { - return promisedRequest(MASTODON_USER_BLOCKS_URL, { headers: authHeaders(credentials) }) + return promisedRequest({ url: MASTODON_USER_BLOCKS_URL, credentials }) .then((users) => users.map(parseUser)) } @@ -638,9 +789,32 @@ const markNotificationsAsSeen = ({id, credentials}) => { }).then((data) => data.json()) } +const fetchFavoritedByUsers = ({id}) => { + return promisedRequest({ url: MASTODON_STATUS_FAVORITEDBY_URL(id) }).then((users) => users.map(parseUser)) +} + +const fetchRebloggedByUsers = ({id}) => { + return promisedRequest({ url: MASTODON_STATUS_REBLOGGEDBY_URL(id) }).then((users) => users.map(parseUser)) +} + +const reportUser = ({credentials, userId, statusIds, comment, forward}) => { + return promisedRequest({ + url: MASTODON_REPORT_USER_URL, + method: 'POST', + payload: { + 'account_id': userId, + 'status_ids': statusIds, + comment, + forward + }, + credentials + }) +} + const apiService = { verifyCredentials, fetchTimeline, + fetchPinnedStatuses, fetchConversation, fetchStatus, fetchFriends, @@ -648,6 +822,8 @@ const apiService = { fetchFollowers, followUser, unfollowUser, + pinOwnStatus, + unpinOwnStatus, blockUser, unblockUser, fetchUser, @@ -659,13 +835,18 @@ const apiService = { postStatus, deleteStatus, uploadMedia, - fetchAllFollowing, fetchMutes, muteUser, unmuteUser, fetchBlocks, fetchOAuthTokens, revokeOAuthToken, + tagUser, + untagUser, + deleteUser, + addRight, + deleteRight, + setActivationStatus, register, getCaptcha, updateAvatar, @@ -673,14 +854,24 @@ const apiService = { updateProfile, updateBanner, externalProfile, - followImport, + importBlocks, + importFollows, deleteAccount, changePassword, + settingsMFA, + mfaDisableOTP, + generateMfaBackupCodes, + mfaSetupOTP, + mfaConfirmOTP, fetchFollowRequests, approveUser, denyUser, suggestions, - markNotificationsAsSeen + markNotificationsAsSeen, + fetchFavoritedByUsers, + fetchRebloggedByUsers, + reportUser, + updateNotificationSettings } export default apiService diff --git a/src/services/backend_interactor_service/backend_interactor_service.js b/src/services/backend_interactor_service/backend_interactor_service.js index 71e78d2f..07093b5c 100644 --- a/src/services/backend_interactor_service/backend_interactor_service.js +++ b/src/services/backend_interactor_service/backend_interactor_service.js @@ -1,5 +1,6 @@ import apiService from '../api/api.service.js' import timelineFetcherService from '../timeline_fetcher/timeline_fetcher.service.js' +import notificationsFetcher from '../notifications_fetcher/notifications_fetcher.service.js' const backendInteractorService = (credentials) => { const fetchStatus = ({id}) => { @@ -22,10 +23,6 @@ const backendInteractorService = (credentials) => { return apiService.fetchFollowers({id, maxId, sinceId, limit, credentials}) } - const fetchAllFollowing = ({username}) => { - return apiService.fetchAllFollowing({username, credentials}) - } - const fetchUser = ({id}) => { return apiService.fetchUser({id, credentials}) } @@ -58,8 +55,40 @@ const backendInteractorService = (credentials) => { return apiService.denyUser({credentials, id}) } - const startFetching = ({timeline, store, userId = false, tag}) => { - return timelineFetcherService.startFetching({timeline, store, credentials, userId, tag}) + const startFetchingTimeline = ({ timeline, store, userId = false, tag }) => { + return timelineFetcherService.startFetching({ timeline, store, credentials, userId, tag }) + } + + const startFetchingNotifications = ({ store }) => { + return notificationsFetcher.startFetching({ store, credentials }) + } + + const tagUser = ({screen_name}, tag) => { + return apiService.tagUser({screen_name, tag, credentials}) + } + + const untagUser = ({screen_name}, tag) => { + return apiService.untagUser({screen_name, tag, credentials}) + } + + const addRight = ({screen_name}, right) => { + return apiService.addRight({screen_name, right, credentials}) + } + + const deleteRight = ({screen_name}, right) => { + return apiService.deleteRight({screen_name, right, credentials}) + } + + const setActivationStatus = ({screen_name}, status) => { + return apiService.setActivationStatus({screen_name, status, credentials}) + } + + const deleteUser = ({screen_name}) => { + return apiService.deleteUser({screen_name, credentials}) + } + + const updateNotificationSettings = ({settings}) => { + return apiService.updateNotificationSettings({credentials, settings}) } const fetchMutes = () => apiService.fetchMutes({credentials}) @@ -69,20 +98,39 @@ const backendInteractorService = (credentials) => { const fetchFollowRequests = () => apiService.fetchFollowRequests({credentials}) const fetchOAuthTokens = () => apiService.fetchOAuthTokens({credentials}) const revokeOAuthToken = (id) => apiService.revokeOAuthToken({id, credentials}) + const fetchPinnedStatuses = (id) => apiService.fetchPinnedStatuses({credentials, id}) + const pinOwnStatus = (id) => apiService.pinOwnStatus({credentials, id}) + const unpinOwnStatus = (id) => apiService.unpinOwnStatus({credentials, id}) const getCaptcha = () => apiService.getCaptcha() - const register = (params) => apiService.register(params) - const updateAvatar = ({params}) => apiService.updateAvatar({credentials, params}) + const register = (params) => apiService.register({ credentials, params }) + const updateAvatar = ({avatar}) => apiService.updateAvatar({credentials, avatar}) const updateBg = ({params}) => apiService.updateBg({credentials, params}) - const updateBanner = ({params}) => apiService.updateBanner({credentials, params}) + const updateBanner = ({banner}) => apiService.updateBanner({credentials, banner}) const updateProfile = ({params}) => apiService.updateProfile({credentials, params}) const externalProfile = (profileUrl) => apiService.externalProfile({profileUrl, credentials}) - const followImport = ({params}) => apiService.followImport({params, credentials}) + const importBlocks = (file) => apiService.importBlocks({file, credentials}) + const importFollows = (file) => apiService.importFollows({file, credentials}) const deleteAccount = ({password}) => apiService.deleteAccount({credentials, password}) const changePassword = ({password, newPassword, newPasswordConfirmation}) => apiService.changePassword({credentials, password, newPassword, newPasswordConfirmation}) + const fetchSettingsMFA = () => apiService.settingsMFA({credentials}) + const generateMfaBackupCodes = () => apiService.generateMfaBackupCodes({credentials}) + const mfaSetupOTP = () => apiService.mfaSetupOTP({credentials}) + const mfaConfirmOTP = ({password, token}) => apiService.mfaConfirmOTP({credentials, password, token}) + const mfaDisableOTP = ({password}) => apiService.mfaDisableOTP({credentials, password}) + + const fetchFavoritedByUsers = (id) => apiService.fetchFavoritedByUsers({id}) + const fetchRebloggedByUsers = (id) => apiService.fetchRebloggedByUsers({id}) + const reportUser = (params) => apiService.reportUser({credentials, ...params}) + + const favorite = (id) => apiService.favorite({id, credentials}) + const unfavorite = (id) => apiService.unfavorite({id, credentials}) + const retweet = (id) => apiService.retweet({id, credentials}) + const unretweet = (id) => apiService.unretweet({id, credentials}) + const backendInteractorServiceInstance = { fetchStatus, fetchConversation, @@ -95,15 +143,24 @@ const backendInteractorService = (credentials) => { unblockUser, fetchUser, fetchUserRelationship, - fetchAllFollowing, verifyCredentials: apiService.verifyCredentials, - startFetching, + startFetchingTimeline, + startFetchingNotifications, fetchMutes, muteUser, unmuteUser, fetchBlocks, fetchOAuthTokens, revokeOAuthToken, + fetchPinnedStatuses, + pinOwnStatus, + unpinOwnStatus, + tagUser, + untagUser, + addRight, + deleteRight, + deleteUser, + setActivationStatus, register, getCaptcha, updateAvatar, @@ -111,12 +168,26 @@ const backendInteractorService = (credentials) => { updateBanner, updateProfile, externalProfile, - followImport, + importBlocks, + importFollows, deleteAccount, changePassword, + fetchSettingsMFA, + generateMfaBackupCodes, + mfaSetupOTP, + mfaConfirmOTP, + mfaDisableOTP, fetchFollowRequests, approveUser, - denyUser + denyUser, + fetchFavoritedByUsers, + fetchRebloggedByUsers, + reportUser, + favorite, + unfavorite, + retweet, + unretweet, + updateNotificationSettings } return backendInteractorServiceInstance diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js index ea57e6b2..0e55ed2a 100644 --- a/src/services/entity_normalizer/entity_normalizer.service.js +++ b/src/services/entity_normalizer/entity_normalizer.service.js @@ -33,16 +33,17 @@ export const parseUser = (data) => { if (masto) { output.screen_name = data.acct + output.statusnet_profile_url = data.url // There's nothing else to get if (mastoShort) { return output } - // output.name = ??? missing + output.name = data.display_name output.name_html = addEmojis(data.display_name, data.emojis) - // output.description = ??? missing + output.description = data.note output.description_html = addEmojis(data.note, data.emojis) // Utilize avatar_static for gif avatars? @@ -56,8 +57,6 @@ export const parseUser = (data) => { output.bot = data.bot - output.statusnet_profile_url = data.url - if (data.pleroma) { const relationship = data.pleroma.relationship @@ -67,9 +66,31 @@ export const parseUser = (data) => { output.statusnet_blocking = relationship.blocking output.muted = relationship.muting } + + output.rights = { + moderator: data.pleroma.is_moderator, + admin: data.pleroma.is_admin + } + // TODO: Clean up in UI? This is duplication from what BE does for qvitterapi + if (output.rights.admin) { + output.role = 'admin' + } else if (output.rights.moderator) { + output.role = 'moderator' + } else { + output.role = 'member' + } } - // Missing, trying to recover + if (data.source) { + output.description = data.source.note + output.default_scope = data.source.privacy + if (data.source.pleroma) { + output.no_rich_text = data.source.pleroma.no_rich_text + output.show_role = data.source.pleroma.show_role + } + } + + // TODO: handle is_local output.is_local = !output.screen_name.includes('@') } else { output.screen_name = data.screen_name @@ -101,9 +122,12 @@ export const parseUser = (data) => { output.muted = data.muted - // QVITTER ONLY FOR NOW - // Really only applies to logged in user, really.. I THINK - output.rights = data.rights + if (data.rights) { + output.rights = { + moderator: data.rights.delete_others_notice, + admin: data.rights.admin + } + } output.no_rich_text = data.no_rich_text output.default_scope = data.default_scope output.hide_follows = data.hide_follows @@ -119,12 +143,23 @@ export const parseUser = (data) => { output.locked = data.locked output.followers_count = data.followers_count output.statuses_count = data.statuses_count - output.friends = [] - output.followers = [] + output.friendIds = [] + output.followerIds = [] + output.pinnedStatuseIds = [] + if (data.pleroma) { output.follow_request_count = data.pleroma.follow_request_count + + output.tags = data.pleroma.tags + output.deactivated = data.pleroma.deactivated + + output.notification_settings = data.pleroma.notification_settings } + output.tags = output.tags || [] + output.rights = output.rights || {} + output.notification_settings = output.notification_settings || {} + return output } @@ -151,7 +186,7 @@ export const addEmojis = (string, emojis) => { return emojis.reduce((acc, emoji) => { return acc.replace( new RegExp(`:${emoji.shortcode}:`, 'g'), - `<img src='${emoji.url}' alt='${emoji.shortcode}' class='emoji' />` + `<img src='${emoji.url}' alt='${emoji.shortcode}' title='${emoji.shortcode}' class='emoji' />` ) }, string) } @@ -172,28 +207,31 @@ export const parseStatus = (data) => { output.statusnet_html = addEmojis(data.content, data.emojis) - // Not exactly the same but works? - output.text = data.content + output.tags = data.tags + + if (data.pleroma) { + const { pleroma } = data + output.text = pleroma.content ? data.pleroma.content['text/plain'] : data.content + output.summary = pleroma.spoiler_text ? data.pleroma.spoiler_text['text/plain'] : data.spoiler_text + output.statusnet_conversation_id = data.pleroma.conversation_id + output.is_local = pleroma.local + output.in_reply_to_screen_name = data.pleroma.in_reply_to_account_acct + } else { + output.text = data.content + output.summary = data.spoiler_text + } output.in_reply_to_status_id = data.in_reply_to_id output.in_reply_to_user_id = data.in_reply_to_account_id output.replies_count = data.replies_count - // Missing!! fix in UI? - // output.in_reply_to_screen_name = ??? - - // Not exactly the same but works - output.statusnet_conversation_id = data.id - if (output.type === 'retweet') { output.retweeted_status = parseStatus(data.reblog) } - output.summary = data.spoiler_text output.summary_html = addEmojis(data.spoiler_text, data.emojis) output.external_url = data.url - - // output.is_local = ??? missing + output.pinned = data.pinned } else { output.favorited = data.favorited output.fave_num = data.fave_num @@ -221,7 +259,6 @@ export const parseStatus = (data) => { output.in_reply_to_status_id = data.in_reply_to_status_id output.in_reply_to_user_id = data.in_reply_to_user_id output.in_reply_to_screen_name = data.in_reply_to_screen_name - output.statusnet_conversation_id = data.statusnet_conversation_id if (output.type === 'retweet') { @@ -259,6 +296,9 @@ export const parseStatus = (data) => { output.retweeted_status = parseStatus(retweetedStatus) } + output.favoritedBy = [] + output.rebloggedBy = [] + return output } @@ -272,9 +312,11 @@ export const parseNotification = (data) => { if (masto) { output.type = mastoDict[data.type] || data.type - // output.seen = ??? missing - output.status = parseStatus(data.status) - output.action = output.status // not sure + output.seen = data.pleroma.is_seen + output.status = output.type === 'follow' + ? null + : parseStatus(data.status) + output.action = output.status // TODO: Refactor, this is unneeded output.from_profile = parseUser(data.account) } else { const parsedNotice = parseStatus(data.notice) @@ -288,7 +330,7 @@ export const parseNotification = (data) => { } output.created_at = new Date(data.created_at) - output.id = data.id + output.id = parseInt(data.id) return output } diff --git a/src/services/follow_manipulate/follow_manipulate.js b/src/services/follow_manipulate/follow_manipulate.js index 51dafe84..b2486e7c 100644 --- a/src/services/follow_manipulate/follow_manipulate.js +++ b/src/services/follow_manipulate/follow_manipulate.js @@ -23,18 +23,12 @@ export const requestFollow = (user, store) => new Promise((resolve, reject) => { // For locked users we just mark it that we sent the follow request if (updated.locked) { - resolve({ - sent: true, - updated - }) + resolve({ sent: true }) } if (updated.following) { // If we get result immediately, just stop. - resolve({ - sent: false, - updated - }) + resolve({ sent: false }) } // But usually we don't get result immediately, so we ask server @@ -48,16 +42,10 @@ export const requestFollow = (user, store) => new Promise((resolve, reject) => { .then((following) => { if (following) { // We confirmed and everything's good. - resolve({ - sent: false, - updated - }) + resolve({ sent: false }) } else { // If after all the tries, just treat it as if user is locked - resolve({ - sent: false, - updated - }) + resolve({ sent: false }) } }) }) diff --git a/src/services/new_api/mfa.js b/src/services/new_api/mfa.js new file mode 100644 index 00000000..ddf90e6b --- /dev/null +++ b/src/services/new_api/mfa.js @@ -0,0 +1,38 @@ +const verifyOTPCode = ({app, instance, mfaToken, code}) => { + const url = `${instance}/oauth/mfa/challenge` + const form = new window.FormData() + + form.append('client_id', app.client_id) + form.append('client_secret', app.client_secret) + form.append('mfa_token', mfaToken) + form.append('code', code) + form.append('challenge_type', 'totp') + + return window.fetch(url, { + method: 'POST', + body: form + }).then((data) => data.json()) +} + +const verifyRecoveryCode = ({app, instance, mfaToken, code}) => { + const url = `${instance}/oauth/mfa/challenge` + const form = new window.FormData() + + form.append('client_id', app.client_id) + form.append('client_secret', app.client_secret) + form.append('mfa_token', mfaToken) + form.append('code', code) + form.append('challenge_type', 'recovery') + + return window.fetch(url, { + method: 'POST', + body: form + }).then((data) => data.json()) +} + +const mfa = { + verifyOTPCode, + verifyRecoveryCode +} + +export default mfa diff --git a/src/services/new_api/oauth.js b/src/services/new_api/oauth.js index 9e656507..030e9980 100644 --- a/src/services/new_api/oauth.js +++ b/src/services/new_api/oauth.js @@ -1,51 +1,57 @@ -import {reduce} from 'lodash' +import { reduce } from 'lodash' + +const REDIRECT_URI = `${window.location.origin}/oauth-callback` + +export const getOrCreateApp = ({ clientId, clientSecret, instance, commit }) => { + if (clientId && clientSecret) { + return Promise.resolve({ clientId, clientSecret }) + } -const getOrCreateApp = ({oauth, instance}) => { const url = `${instance}/api/v1/apps` const form = new window.FormData() - form.append('client_name', `PleromaFE_${Math.random()}`) - form.append('redirect_uris', `${window.location.origin}/oauth-callback`) + form.append('client_name', `PleromaFE_${window.___pleromafe_commit_hash}_${(new Date()).toISOString()}`) + form.append('redirect_uris', REDIRECT_URI) form.append('scopes', 'read write follow') return window.fetch(url, { method: 'POST', body: form - }).then((data) => data.json()) + }) + .then((data) => data.json()) + .then((app) => ({ clientId: app.client_id, clientSecret: app.client_secret })) + .then((app) => commit('setClientData', app) || app) } -const login = (args) => { - getOrCreateApp(args).then((app) => { - args.commit('setClientData', app) - - const data = { - response_type: 'code', - client_id: app.client_id, - redirect_uri: app.redirect_uri, - scope: 'read write follow' - } - const dataString = reduce(data, (acc, v, k) => { - const encoded = `${k}=${encodeURIComponent(v)}` - if (!acc) { - return encoded - } else { - return `${acc}&${encoded}` - } - }, false) +const login = ({ instance, clientId }) => { + const data = { + response_type: 'code', + client_id: clientId, + redirect_uri: REDIRECT_URI, + scope: 'read write follow' + } + + const dataString = reduce(data, (acc, v, k) => { + const encoded = `${k}=${encodeURIComponent(v)}` + if (!acc) { + return encoded + } else { + return `${acc}&${encoded}` + } + }, false) - // Do the redirect... - const url = `${args.instance}/oauth/authorize?${dataString}` + // Do the redirect... + const url = `${instance}/oauth/authorize?${dataString}` - window.location.href = url - }) + window.location.href = url } -const getTokenWithCredentials = ({app, instance, username, password}) => { +const getTokenWithCredentials = ({ clientId, clientSecret, instance, username, password }) => { const url = `${instance}/oauth/token` const form = new window.FormData() - form.append('client_id', app.client_id) - form.append('client_secret', app.client_secret) + form.append('client_id', clientId) + form.append('client_secret', clientSecret) form.append('grant_type', 'password') form.append('username', username) form.append('password', password) @@ -56,12 +62,12 @@ const getTokenWithCredentials = ({app, instance, username, password}) => { }).then((data) => data.json()) } -const getToken = ({app, instance, code}) => { +const getToken = ({ clientId, clientSecret, instance, code }) => { const url = `${instance}/oauth/token` const form = new window.FormData() - form.append('client_id', app.client_id) - form.append('client_secret', app.client_secret) + form.append('client_id', clientId) + form.append('client_secret', clientSecret) form.append('grant_type', 'authorization_code') form.append('code', code) form.append('redirect_uri', `${window.location.origin}/oauth-callback`) @@ -69,6 +75,53 @@ const getToken = ({app, instance, code}) => { return window.fetch(url, { method: 'POST', body: form + }) + .then((data) => data.json()) +} + +export const getClientToken = ({ clientId, clientSecret, instance }) => { + const url = `${instance}/oauth/token` + const form = new window.FormData() + + form.append('client_id', clientId) + form.append('client_secret', clientSecret) + form.append('grant_type', 'client_credentials') + form.append('redirect_uri', `${window.location.origin}/oauth-callback`) + + return window.fetch(url, { + method: 'POST', + body: form + }).then((data) => data.json()) +} +const verifyOTPCode = ({app, instance, mfaToken, code}) => { + const url = `${instance}/oauth/mfa/challenge` + const form = new window.FormData() + + form.append('client_id', app.client_id) + form.append('client_secret', app.client_secret) + form.append('mfa_token', mfaToken) + form.append('code', code) + form.append('challenge_type', 'totp') + + return window.fetch(url, { + method: 'POST', + body: form + }).then((data) => data.json()) +} + +const verifyRecoveryCode = ({app, instance, mfaToken, code}) => { + const url = `${instance}/oauth/mfa/challenge` + const form = new window.FormData() + + form.append('client_id', app.client_id) + form.append('client_secret', app.client_secret) + form.append('mfa_token', mfaToken) + form.append('code', code) + form.append('challenge_type', 'recovery') + + return window.fetch(url, { + method: 'POST', + body: form }).then((data) => data.json()) } @@ -76,7 +129,9 @@ const oauth = { login, getToken, getTokenWithCredentials, - getOrCreateApp + getOrCreateApp, + verifyOTPCode, + verifyRecoveryCode } export default oauth diff --git a/src/services/new_api/utils.js b/src/services/new_api/utils.js index 078f392f..6696573b 100644 --- a/src/services/new_api/utils.js +++ b/src/services/new_api/utils.js @@ -5,9 +5,9 @@ const queryParams = (params) => { } const headers = (store) => { - const accessToken = store.state.oauth.token + const accessToken = store.getters.getToken() if (accessToken) { - return {'Authorization': `Bearer ${accessToken}`} + return { 'Authorization': `Bearer ${accessToken}` } } else { return {} } diff --git a/src/services/notification_utils/notification_utils.js b/src/services/notification_utils/notification_utils.js index cd8f3f9e..f9cbbade 100644 --- a/src/services/notification_utils/notification_utils.js +++ b/src/services/notification_utils/notification_utils.js @@ -10,8 +10,8 @@ export const visibleTypes = store => ([ ].filter(_ => _)) const sortById = (a, b) => { - const seqA = Number(a.action.id) - const seqB = Number(b.action.id) + const seqA = Number(a.id) + const seqB = Number(b.id) const isSeqA = !Number.isNaN(seqA) const isSeqB = !Number.isNaN(seqB) if (isSeqA && isSeqB) { @@ -21,15 +21,17 @@ const sortById = (a, b) => { } else if (!isSeqA && isSeqB) { return -1 } else { - return a.action.id > b.action.id ? -1 : 1 + return a.id > b.id ? -1 : 1 } } -export const visibleNotificationsFromStore = store => { +export const visibleNotificationsFromStore = (store, types) => { // map is just to clone the array since sort mutates it and it causes some issues let sortedNotifications = notificationsFromStore(store).map(_ => _).sort(sortById) sortedNotifications = sortBy(sortedNotifications, 'seen') - return sortedNotifications.filter((notification) => visibleTypes(store).includes(notification.type)) + return sortedNotifications.filter( + (notification) => (types || visibleTypes(store)).includes(notification.type) + ) } export const unseenNotificationsFromStore = store => diff --git a/src/services/notifications_fetcher/notifications_fetcher.service.js b/src/services/notifications_fetcher/notifications_fetcher.service.js index 3ecdae6a..60c497ae 100644 --- a/src/services/notifications_fetcher/notifications_fetcher.service.js +++ b/src/services/notifications_fetcher/notifications_fetcher.service.js @@ -11,29 +11,35 @@ const fetchAndUpdate = ({store, credentials, older = false}) => { const rootState = store.rootState || store.state const timelineData = rootState.statuses.notifications + args['timeline'] = 'notifications' if (older) { if (timelineData.minId !== Number.POSITIVE_INFINITY) { args['until'] = timelineData.minId } + return fetchNotifications({ store, args, older }) } else { - // load unread notifications repeadedly to provide consistency between browser tabs + // fetch new notifications + if (timelineData.maxId !== Number.POSITIVE_INFINITY) { + args['since'] = timelineData.maxId + } + const result = fetchNotifications({ store, args, older }) + + // load unread notifications repeatedly to provide consistency between browser tabs const notifications = timelineData.data const unread = notifications.filter(n => !n.seen).map(n => n.id) - if (!unread.length) { - args['since'] = timelineData.maxId - } else { - args['since'] = Math.min(...unread) - 1 - if (timelineData.maxId !== Math.max(...unread)) { - args['until'] = Math.max(...unread, args['since'] + 20) - } + if (unread.length) { + args['since'] = Math.min(...unread) + fetchNotifications({ store, args, older }) } - } - args['timeline'] = 'notifications' + return result + } +} +const fetchNotifications = ({ store, args, older }) => { return apiService.fetchTimeline(args) .then((notifications) => { - update({store, notifications, older}) + update({ store, notifications, older }) return notifications }, () => store.dispatch('setNotificationsError', { value: true })) .catch(() => store.dispatch('setNotificationsError', { value: true })) diff --git a/src/services/window_utils/window_utils.js b/src/services/window_utils/window_utils.js new file mode 100644 index 00000000..faff6cb9 --- /dev/null +++ b/src/services/window_utils/window_utils.js @@ -0,0 +1,5 @@ + +export const windowWidth = () => + window.innerWidth || + document.documentElement.clientWidth || + document.body.clientWidth |
