diff options
Diffstat (limited to 'src')
99 files changed, 3298 insertions, 1009 deletions
@@ -68,10 +68,14 @@ export default { logo () { return this.$store.state.instance.logo }, bgStyle () { return { - '--body-background-image': `url(${this.background})`, 'background-image': `url(${this.background})` } }, + bgAppStyle () { + return { + '--body-background-image': `url(${this.background})` + } + }, sitename () { return this.$store.state.instance.name }, chat () { return this.$store.state.chat.channel.state === 'joined' }, suggestionsEnabled () { return this.$store.state.instance.suggestionsEnabled }, diff --git a/src/App.scss b/src/App.scss index 52484f59..a0d1a804 100644 --- a/src/App.scss +++ b/src/App.scss @@ -181,8 +181,7 @@ input, textarea, .select { color: $fallback--text; color: var(--text, $fallback--text); } - &:disabled, - { + &:disabled { &, & + label, & + label::before { @@ -629,6 +628,16 @@ nav { color: $fallback--faint; color: var(--faint, $fallback--faint); } + +.faint-link { + color: $fallback--faint; + color: var(--faint, $fallback--faint); + + &:hover { + text-decoration: underline; + } +} + @media all and (min-width: 800px) { .logo { opacity: 1 !important; @@ -649,10 +658,6 @@ nav { color: var(--lightText, $fallback--lightText); } - .text-format { - float: right; - } - div { padding-top: 5px; } @@ -666,6 +671,10 @@ nav { border-radius: var(--inputRadius, $fallback--inputRadius); } +.button-icon { + font-size: 1.2em; +} + @keyframes shakeError { 0% { transform: translateX(0); @@ -710,16 +719,6 @@ nav { margin: 0.5em 0 0.5em 0; } - .button-icon { - font-size: 1.2em; - } - - .status .status-actions { - div { - max-width: 4em; - } - } - .menu-button { display: block; margin-right: 0.8em; @@ -728,7 +727,7 @@ nav { .login-hint { text-align: center; - + @media all and (min-width: 801px) { display: none; } @@ -739,3 +738,7 @@ nav { width: 100%; } } + +.btn.btn-default { + min-height: 28px; +} diff --git a/src/App.vue b/src/App.vue index fa5736e5..acbbeb75 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,5 +1,5 @@ <template> - <div id="app"> + <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"> <div class='logo' :style='logoBgStyle'> diff --git a/src/boot/after_store.js b/src/boot/after_store.js index 53ecc083..a8e2bf35 100644 --- a/src/boot/after_store.js +++ b/src/boot/after_store.js @@ -169,6 +169,8 @@ const afterStoreSetup = ({ store, i18n }) => { store.dispatch('setInstanceOption', { name: 'chatAvailable', value: features.includes('chat') }) store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') }) + store.dispatch('setInstanceOption', { name: 'postFormats', value: metadata.postFormats }) + store.dispatch('setInstanceOption', { name: 'restrictedNicknames', value: metadata.restrictedNicknames }) const suggestions = metadata.suggestions diff --git a/src/components/attachment/attachment.vue b/src/components/attachment/attachment.vue index a93c9014..c58bebd3 100644 --- a/src/components/attachment/attachment.vue +++ b/src/components/attachment/attachment.vue @@ -88,7 +88,7 @@ .attachment { position: relative; - margin: 0.5em 0.5em 0em 0em; + margin-top: 0.5em; align-self: flex-start; line-height: 0; diff --git a/src/components/autocomplete_input/autocomplete_input.js b/src/components/autocomplete_input/autocomplete_input.js deleted file mode 100644 index 1544e7bb..00000000 --- a/src/components/autocomplete_input/autocomplete_input.js +++ /dev/null @@ -1,149 +0,0 @@ -import Completion from '../../services/completion/completion.js' -import { take, filter, map } from 'lodash' - -const AutoCompleteInput = { - props: [ - 'id', - 'classObj', - 'value', - 'placeholder', - 'autoResize', - 'multiline', - 'drop', - 'dragoverPrevent', - 'paste', - 'keydownMetaEnter', - 'keyupCtrlEnter' - ], - components: {}, - mounted () { - this.autoResize && this.resize(this.$refs.textarea) - const textLength = this.$refs.textarea.value.length - this.$refs.textarea.setSelectionRange(textLength, textLength) - }, - data () { - return { - caret: 0, - highlighted: 0 - } - }, - computed: { - users () { - return this.$store.state.users.users - }, - emoji () { - return this.$store.state.instance.emoji || [] - }, - customEmoji () { - return this.$store.state.instance.customEmoji || [] - }, - textAtCaret () { - return (this.wordAtCaret || {}).word || '' - }, - wordAtCaret () { - const word = Completion.wordAtPosition(this.value, this.caret - 1) || {} - return word - }, - candidates () { - const firstchar = this.textAtCaret.charAt(0) - if (firstchar === '@') { - const query = this.textAtCaret.slice(1).toUpperCase() - const matchedUsers = filter(this.users, (user) => { - return user.screen_name.toUpperCase().startsWith(query) || - user.name && user.name.toUpperCase().startsWith(query) - }) - if (matchedUsers.length <= 0) { - return false - } - // eslint-disable-next-line camelcase - return map(take(matchedUsers, 5), ({screen_name, name, profile_image_url_original}, index) => ({ - // eslint-disable-next-line camelcase - screen_name: `@${screen_name}`, - name: name, - img: profile_image_url_original, - highlighted: index === this.highlighted - })) - } else if (firstchar === ':') { - if (this.textAtCaret === ':') { return } - const matchedEmoji = filter(this.emoji.concat(this.customEmoji), (emoji) => emoji.shortcode.startsWith(this.textAtCaret.slice(1))) - if (matchedEmoji.length <= 0) { - return false - } - return map(take(matchedEmoji, 5), ({shortcode, image_url, utf}, index) => ({ - screen_name: `:${shortcode}:`, - name: '', - utf: utf || '', - // eslint-disable-next-line camelcase - img: utf ? '' : this.$store.state.instance.server + image_url, - highlighted: index === this.highlighted - })) - } else { - return false - } - } - }, - methods: { - setCaret ({target: {selectionStart}}) { - this.caret = selectionStart - }, - cycleBackward (e) { - const len = this.candidates.length || 0 - if (len > 0) { - e.preventDefault() - this.highlighted -= 1 - if (this.highlighted < 0) { - this.highlighted = this.candidates.length - 1 - } - } else { - this.highlighted = 0 - } - }, - cycleForward (e) { - const len = this.candidates.length || 0 - if (len > 0) { - if (e.shiftKey) { return } - e.preventDefault() - this.highlighted += 1 - if (this.highlighted >= len) { - this.highlighted = 0 - } - } else { - this.highlighted = 0 - } - }, - replace (replacement) { - this.$emit('input', Completion.replaceWord(this.value, this.wordAtCaret, replacement)) - const el = this.$el.querySelector('textarea') || this.$el.querySelector('input') - el.focus() - this.caret = 0 - }, - replaceCandidate (e) { - const len = this.candidates.length || 0 - if (this.textAtCaret === ':' || e.ctrlKey) { return } - if (len > 0) { - e.preventDefault() - const candidate = this.candidates[this.highlighted] - const replacement = candidate.utf || (candidate.screen_name + ' ') - this.$emit('input', Completion.replaceWord(this.value, this.wordAtCaret, replacement)) - const el = this.$el.querySelector('textarea') || this.$el.querySelector('input') - el.focus() - this.caret = 0 - this.highlighted = 0 - } - }, - resize (e) { - const target = e.target || e - if (!(target instanceof window.Element)) { return } - const vertPadding = Number(window.getComputedStyle(target)['padding-top'].substr(0, 1)) + - Number(window.getComputedStyle(target)['padding-bottom'].substr(0, 1)) - // Auto is needed to make textbox shrink when removing lines - target.style.height = 'auto' - target.style.height = `${target.scrollHeight - vertPadding}px` - if (target.value === '') { - target.style.height = null - } - } - } -} - -export default AutoCompleteInput diff --git a/src/components/autocomplete_input/autocomplete_input.vue b/src/components/autocomplete_input/autocomplete_input.vue deleted file mode 100644 index 1e26b76b..00000000 --- a/src/components/autocomplete_input/autocomplete_input.vue +++ /dev/null @@ -1,104 +0,0 @@ -<template> - <div style="display: flex; flex-direction: column;"> - <textarea - v-if="multiline" - ref="textarea" - rows="1" - :value="value" :class="classObj" :id="id" :placeholder="placeholder" - @input="$emit('input', $event.target.value), autoResize && resize($event)" - @click="setCaret" - @keyup="setCaret" - @keydown.down="cycleForward" - @keydown.up="cycleBackward" - @keydown.shift.tab="cycleBackward" - @keydown.tab="cycleForward" - @keydown.enter="replaceCandidate" - @drop="drop && drop($event)" - @dragover.prevent="dragoverPrevent && dragoverPrevent($event)" - @paste="paste && paste($event)" - @keydown.meta.enter="keydownMetaEnter && keydownMetaEnter($event)" - @keyup.ctrl.enter="keyupCtrlEnter && keyupCtrlEnter($event)"> - </textarea> - <input - v-else - ref="textarea" - :value="value" :class="classObj" :id="id" :placeholder="placeholder" - @input="$emit('input', $event.target.value), autoResize && resize($event)" - @click="setCaret" - @keyup="setCaret" - @keydown.down="cycleForward" - @keydown.up="cycleBackward" - @keydown.shift.tab="cycleBackward" - @keydown.tab="cycleForward" - @keydown.enter="replaceCandidate" - @drop="drop && drop($event)" - @dragover.prevent="dragoverPrevent && dragoverPrevent($event)" - @paste="paste && paste($event)" - @keydown.meta.enter="keydownMetaEnter && keydownMetaEnter($event)" - @keyup.ctrl.enter="keyupCtrlEnter && keyupCtrlEnter($event)"/> - <div style="position:relative;" v-if="candidates"> - <div class="autocomplete-panel"> - <div v-for="candidate in candidates" @click="replace(candidate.utf || (candidate.screen_name + ' '))"> - <div class="autocomplete" :class="{ highlighted: candidate.highlighted }"> - <span v-if="candidate.img"><img :src="candidate.img"></img></span> - <span v-else>{{candidate.utf}}</span> - <span>{{candidate.screen_name}}<small>{{candidate.name}}</small></span> - </div> - </div> - </div> - </div> - </div> -</template> - -<script src="./autocomplete_input.js"></script> - -<style lang="scss"> -@import '../../_variables.scss'; - -.autocomplete-panel { - margin: 0 0.5em 0 0.5em; - border-radius: $fallback--tooltipRadius; - border-radius: var(--tooltipRadius, $fallback--tooltipRadius); - position: absolute; - z-index: 1; - box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5); - // this doesn't match original but i don't care, making it uniform. - box-shadow: var(--popupShadow); - min-width: 75%; - background: $fallback--bg; - background: var(--bg, $fallback--bg); - color: $fallback--lightText; - color: var(--lightText, $fallback--lightText); -} - -.autocomplete { - cursor: pointer; - padding: 0.2em 0.4em 0.2em 0.4em; - border-bottom: 1px solid rgba(0, 0, 0, 0.4); - display: flex; - - img { - width: 24px; - height: 24px; - border-radius: $fallback--avatarRadius; - border-radius: var(--avatarRadius, $fallback--avatarRadius); - object-fit: contain; - } - - span { - line-height: 24px; - margin: 0 0.1em 0 0.2em; - } - - small { - margin-left: .5em; - color: $fallback--faint; - color: var(--faint, $fallback--faint); - } - - &.highlighted { - background-color: $fallback--fg; - background-color: var(--lightBg, $fallback--fg); - } -} -</style> diff --git a/src/components/basic_user_card/basic_user_card.js b/src/components/basic_user_card/basic_user_card.js new file mode 100644 index 00000000..a8441446 --- /dev/null +++ b/src/components/basic_user_card/basic_user_card.js @@ -0,0 +1,28 @@ +import UserCardContent from '../user_card_content/user_card_content.vue' +import UserAvatar from '../user_avatar/user_avatar.vue' +import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' + +const BasicUserCard = { + props: [ + 'user' + ], + data () { + return { + userExpanded: false + } + }, + components: { + UserCardContent, + UserAvatar + }, + methods: { + toggleUserExpanded () { + this.userExpanded = !this.userExpanded + }, + userProfileLink (user) { + return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames) + } + } +} + +export default BasicUserCard diff --git a/src/components/basic_user_card/basic_user_card.vue b/src/components/basic_user_card/basic_user_card.vue new file mode 100644 index 00000000..77fb0aa0 --- /dev/null +++ b/src/components/basic_user_card/basic_user_card.vue @@ -0,0 +1,79 @@ +<template> + <div class="user-card"> + <router-link :to="userProfileLink(user)"> + <UserAvatar class="avatar" @click.prevent.native="toggleUserExpanded" :src="user.profile_image_url"/> + </router-link> + <div class="user-card-expanded-content" v-if="userExpanded"> + <user-card-content :user="user" :switcher="false"></user-card-content> + </div> + <div class="user-card-collapsed-content" v-else> + <div :title="user.name" class="user-card-user-name"> + <span v-if="user.name_html" v-html="user.name_html"></span> + <span v-else>{{ user.name }}</span> + </div> + <div> + <router-link class="user-card-screen-name" :to="userProfileLink(user)"> + @{{user.screen_name}} + </router-link> + </div> + <slot></slot> + </div> + </div> +</template> + +<script src="./basic_user_card.js"></script> + +<style lang="scss"> +@import '../../_variables.scss'; + +.user-card { + display: flex; + flex: 1 0; + padding-top: 0.6em; + padding-right: 1em; + padding-bottom: 0.6em; + padding-left: 1em; + border-bottom: 1px solid; + margin: 0; + border-bottom-color: $fallback--border; + border-bottom-color: var(--border, $fallback--border); + + &-collapsed-content { + margin-left: 0.7em; + text-align: left; + flex: 1; + min-width: 0; + } + + &-user-name { + img { + object-fit: contain; + height: 16px; + width: 16px; + vertical-align: middle; + } + } + + &-expanded-content { + flex: 1; + margin-left: 0.7em; + border-radius: $fallback--panelRadius; + border-radius: var(--panelRadius, $fallback--panelRadius); + border-style: solid; + border-color: $fallback--border; + border-color: var(--border, $fallback--border); + border-width: 1px; + overflow: hidden; + + .panel-heading { + background: transparent; + flex-direction: column; + align-items: stretch; + } + + p { + margin-bottom: 0; + } + } +} +</style> diff --git a/src/components/block_card/block_card.js b/src/components/block_card/block_card.js new file mode 100644 index 00000000..11fa27b4 --- /dev/null +++ b/src/components/block_card/block_card.js @@ -0,0 +1,37 @@ +import BasicUserCard from '../basic_user_card/basic_user_card.vue' + +const BlockCard = { + props: ['userId'], + data () { + return { + progress: false + } + }, + computed: { + user () { + return this.$store.getters.userById(this.userId) + }, + blocked () { + return this.user.statusnet_blocking + } + }, + components: { + BasicUserCard + }, + methods: { + unblockUser () { + this.progress = true + this.$store.dispatch('unblockUser', this.user.id).then(() => { + this.progress = false + }) + }, + blockUser () { + this.progress = true + this.$store.dispatch('blockUser', this.user.id).then(() => { + this.progress = false + }) + } + } +} + +export default BlockCard diff --git a/src/components/block_card/block_card.vue b/src/components/block_card/block_card.vue new file mode 100644 index 00000000..8eb56e25 --- /dev/null +++ b/src/components/block_card/block_card.vue @@ -0,0 +1,34 @@ +<template> + <basic-user-card :user="user"> + <div class="block-card-content-container"> + <button class="btn btn-default" @click="unblockUser" :disabled="progress" v-if="blocked"> + <template v-if="progress"> + {{ $t('user_card.unblock_progress') }} + </template> + <template v-else> + {{ $t('user_card.unblock') }} + </template> + </button> + <button class="btn btn-default" @click="blockUser" :disabled="progress" v-else> + <template v-if="progress"> + {{ $t('user_card.block_progress') }} + </template> + <template v-else> + {{ $t('user_card.block') }} + </template> + </button> + </div> + </basic-user-card> +</template> + +<script src="./block_card.js"></script> + +<style lang="scss"> +.block-card-content-container { + margin-top: 0.5em; + text-align: right; + button { + width: 10em; + } +} +</style> diff --git a/src/components/chat_panel/chat_panel.vue b/src/components/chat_panel/chat_panel.vue index bf65efc5..b37469ac 100644 --- a/src/components/chat_panel/chat_panel.vue +++ b/src/components/chat_panel/chat_panel.vue @@ -3,8 +3,8 @@ <div class="panel panel-default"> <div class="panel-heading timeline-heading" :class="{ 'chat-heading': floating }" @click.stop.prevent="togglePanel"> <div class="title"> - {{$t('chat.title')}} - <i class="icon-cancel" style="float: right;" v-if="floating"></i> + <span>{{$t('chat.title')}}</span> + <i class="icon-cancel" v-if="floating"></i> </div> </div> <div class="chat-window" v-chat-scroll> @@ -98,4 +98,11 @@ resize: none; } } + +.chat-panel { + .title { + display: flex; + justify-content: space-between; + } +} </style> diff --git a/src/components/follow_card/follow_card.js b/src/components/follow_card/follow_card.js new file mode 100644 index 00000000..425c9c3e --- /dev/null +++ b/src/components/follow_card/follow_card.js @@ -0,0 +1,45 @@ +import BasicUserCard from '../basic_user_card/basic_user_card.vue' +import { requestFollow, requestUnfollow } from '../../services/follow_manipulate/follow_manipulate' + +const FollowCard = { + props: [ + 'user', + 'noFollowsYou' + ], + data () { + return { + inProgress: false, + requestSent: false, + updated: false + } + }, + components: { + BasicUserCard + }, + 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 + } + }, + methods: { + followUser () { + this.inProgress = true + requestFollow(this.user, this.$store).then(({ sent, updated }) => { + this.inProgress = false + this.requestSent = sent + this.updated = updated + }) + }, + unfollowUser () { + this.inProgress = true + requestUnfollow(this.user, this.$store).then(({ updated }) => { + this.inProgress = false + this.updated = updated + }) + } + } +} + +export default FollowCard diff --git a/src/components/follow_card/follow_card.vue b/src/components/follow_card/follow_card.vue new file mode 100644 index 00000000..6cb064eb --- /dev/null +++ b/src/components/follow_card/follow_card.vue @@ -0,0 +1,53 @@ +<template> + <basic-user-card :user="user"> + <div class="follow-card-content-container"> + <span class="faint" v-if="!noFollowsYou && user.follows_you"> + {{ isMe ? $t('user_card.its_you') : $t('user_card.follows_you') }} + </span> + <button + v-if="showFollow" + class="btn btn-default" + @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 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> + </div> + </basic-user-card> +</template> + +<script src="./follow_card.js"></script> + +<style lang="scss"> +.follow-card-content-container { + flex-shrink: 0; + display: flex; + flex-direction: row; + justify-content: space-between; + flex-wrap: wrap; + line-height: 1.5em; + + .btn { + margin-top: 0.5em; + margin-left: auto; + width: 10em; + } +} +</style> diff --git a/src/components/follow_list/follow_list.js b/src/components/follow_list/follow_list.js deleted file mode 100644 index acdb216d..00000000 --- a/src/components/follow_list/follow_list.js +++ /dev/null @@ -1,63 +0,0 @@ -import UserCard from '../user_card/user_card.vue' - -const FollowList = { - data () { - return { - loading: false, - bottomedOut: false, - error: false - } - }, - props: ['userId', 'showFollowers'], - created () { - window.addEventListener('scroll', this.scrollLoad) - if (this.entries.length === 0) { - this.fetchEntries() - } - }, - destroyed () { - window.removeEventListener('scroll', this.scrollLoad) - this.$store.dispatch('clearFriendsAndFollowers', this.userId) - }, - computed: { - user () { - return this.$store.getters.userById(this.userId) - }, - entries () { - return this.showFollowers ? this.user.followers : this.user.friends - }, - showActions () { return this.$store.state.users.currentUser.id === this.userId } - }, - methods: { - fetchEntries () { - if (!this.loading) { - const command = this.showFollowers ? 'addFollowers' : 'addFriends' - this.loading = true - this.$store.dispatch(command, this.userId).then(entries => { - this.error = false - this.loading = false - this.bottomedOut = entries.length === 0 - }).catch(() => { - this.error = true - this.loading = false - }) - } - }, - scrollLoad (e) { - const bodyBRect = document.body.getBoundingClientRect() - const height = Math.max(bodyBRect.height, -(bodyBRect.y)) - if (this.loading === false && - this.bottomedOut === false && - this.$el.offsetHeight > 0 && - (window.innerHeight + window.pageYOffset) >= (height - 750) - ) { - this.fetchEntries() - } - } - }, - components: { - UserCard - } -} - -export default FollowList diff --git a/src/components/follow_list/follow_list.vue b/src/components/follow_list/follow_list.vue deleted file mode 100644 index 7be2e7b7..00000000 --- a/src/components/follow_list/follow_list.vue +++ /dev/null @@ -1,34 +0,0 @@ -<template> - <div class="follow-list"> - <user-card - v-for="entry in entries" - :key="entry.id" :user="entry" - :showFollows="!showFollowers" - :showActions="showActions" - /> - <div class="text-center panel-footer"> - <a v-if="error" @click="fetchEntries" class="alert error"> - {{$t('general.generic_error')}} - </a> - <i v-else-if="loading" class="icon-spin3 animate-spin"/> - <span v-else-if="bottomedOut"></span> - <a v-else @click="fetchEntries">{{$t('general.more')}}</a> - </div> - </div> -</template> - -<script src="./follow_list.js"></script> - -<style lang="scss"> - -.follow-list { - .panel-footer { - padding: 10px; - } - - .error { - font-size: 14px; - } -} - -</style> diff --git a/src/components/follow_request_card/follow_request_card.js b/src/components/follow_request_card/follow_request_card.js new file mode 100644 index 00000000..1a00a1c1 --- /dev/null +++ b/src/components/follow_request_card/follow_request_card.js @@ -0,0 +1,20 @@ +import BasicUserCard from '../basic_user_card/basic_user_card.vue' + +const FollowRequestCard = { + props: ['user'], + components: { + BasicUserCard + }, + methods: { + approveUser () { + this.$store.state.api.backendInteractor.approveUser(this.user.id) + this.$store.dispatch('removeFollowRequest', this.user) + }, + denyUser () { + this.$store.state.api.backendInteractor.denyUser(this.user.id) + this.$store.dispatch('removeFollowRequest', this.user) + } + } +} + +export default FollowRequestCard diff --git a/src/components/follow_request_card/follow_request_card.vue b/src/components/follow_request_card/follow_request_card.vue new file mode 100644 index 00000000..4a3bbba4 --- /dev/null +++ b/src/components/follow_request_card/follow_request_card.vue @@ -0,0 +1,29 @@ +<template> + <basic-user-card :user="user"> + <div class="follow-request-card-content-container"> + <button class="btn btn-default" @click="approveUser">{{ $t('user_card.approve') }}</button> + <button class="btn btn-default" @click="denyUser">{{ $t('user_card.deny') }}</button> + </div> + </basic-user-card> +</template> + +<script src="./follow_request_card.js"></script> + +<style lang="scss"> +.follow-request-card-content-container { + display: flex; + flex-direction: row; + flex-wrap: wrap; + button { + margin-top: 0.5em; + margin-right: 0.5em; + flex: 1 1; + max-width: 12em; + min-width: 8em; + + &:last-child { + margin-right: 0; + } + } +} +</style> diff --git a/src/components/follow_requests/follow_requests.js b/src/components/follow_requests/follow_requests.js index 11a228aa..704a76c6 100644 --- a/src/components/follow_requests/follow_requests.js +++ b/src/components/follow_requests/follow_requests.js @@ -1,22 +1,13 @@ -import UserCard from '../user_card/user_card.vue' +import FollowRequestCard from '../follow_request_card/follow_request_card.vue' const FollowRequests = { components: { - UserCard - }, - created () { - this.updateRequests() + FollowRequestCard }, computed: { requests () { return this.$store.state.api.followRequests } - }, - methods: { - updateRequests () { - this.$store.state.api.backendInteractor.fetchFollowRequests() - .then((requests) => { this.$store.commit('setFollowRequests', requests) }) - } } } diff --git a/src/components/follow_requests/follow_requests.vue b/src/components/follow_requests/follow_requests.vue index 87dc4194..b83c2d68 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"> - <user-card v-for="request in requests" :key="request.id" :user="request" :showFollows="false" :showApproval="true"></user-card> + <FollowRequestCard v-for="request in requests" :key="request.id" :user="request"/> </div> </div> </template> diff --git a/src/components/gallery/gallery.vue b/src/components/gallery/gallery.vue index 3f90caa9..ea525c95 100644 --- a/src/components/gallery/gallery.vue +++ b/src/components/gallery/gallery.vue @@ -27,7 +27,6 @@ align-content: stretch; flex-grow: 1; margin-top: 0.5em; - margin-bottom: 0.25em; .attachments, .attachment { margin: 0 0.5em 0 0; @@ -36,6 +35,9 @@ box-sizing: border-box; // to make failed images a bit more noticeable on chromium min-width: 2em; + &:last-child { + margin: 0; + } } .image-attachment { diff --git a/src/components/image_cropper/image_cropper.js b/src/components/image_cropper/image_cropper.js new file mode 100644 index 00000000..49d51846 --- /dev/null +++ b/src/components/image_cropper/image_cropper.js @@ -0,0 +1,128 @@ +import Cropper from 'cropperjs' +import 'cropperjs/dist/cropper.css' + +const ImageCropper = { + props: { + trigger: { + type: [String, window.Element], + required: true + }, + submitHandler: { + type: Function, + required: true + }, + cropperOptions: { + type: Object, + default () { + return { + aspectRatio: 1, + autoCropArea: 1, + viewMode: 1, + movable: false, + zoomable: false, + guides: false + } + } + }, + mimes: { + type: String, + default: 'image/png, image/gif, image/jpeg, image/bmp, image/x-icon' + }, + saveButtonLabel: { + type: String + }, + cancelButtonLabel: { + type: String + } + }, + data () { + return { + cropper: undefined, + dataUrl: undefined, + filename: undefined, + submitting: false, + submitError: null + } + }, + computed: { + saveText () { + return this.saveButtonLabel || this.$t('image_cropper.save') + }, + cancelText () { + return this.cancelButtonLabel || this.$t('image_cropper.cancel') + }, + submitErrorMsg () { + return this.submitError && this.submitError instanceof Error ? this.submitError.toString() : this.submitError + } + }, + methods: { + destroy () { + if (this.cropper) { + this.cropper.destroy() + } + this.$refs.input.value = '' + this.dataUrl = undefined + this.$emit('close') + }, + submit () { + this.submitting = true + this.avatarUploadError = null + this.submitHandler(this.cropper, this.file) + .then(() => this.destroy()) + .catch((err) => { + this.submitError = err + }) + .finally(() => { + this.submitting = false + }) + }, + pickImage () { + this.$refs.input.click() + }, + createCropper () { + this.cropper = new Cropper(this.$refs.img, this.cropperOptions) + }, + getTriggerDOM () { + return typeof this.trigger === 'object' ? this.trigger : document.querySelector(this.trigger) + }, + readFile () { + const fileInput = this.$refs.input + if (fileInput.files != null && fileInput.files[0] != null) { + this.file = fileInput.files[0] + let reader = new window.FileReader() + reader.onload = (e) => { + this.dataUrl = e.target.result + this.$emit('open') + } + reader.readAsDataURL(this.file) + this.$emit('changed', this.file, reader) + } + }, + clearError () { + this.submitError = null + } + }, + mounted () { + // listen for click event on trigger + const trigger = this.getTriggerDOM() + if (!trigger) { + this.$emit('error', 'No image make trigger found.', 'user') + } else { + trigger.addEventListener('click', this.pickImage) + } + // listen for input file changes + const fileInput = this.$refs.input + fileInput.addEventListener('change', this.readFile) + }, + beforeDestroy: function () { + // remove the event listeners + const trigger = this.getTriggerDOM() + if (trigger) { + trigger.removeEventListener('click', this.pickImage) + } + const fileInput = this.$refs.input + fileInput.removeEventListener('change', this.readFile) + } +} + +export default ImageCropper diff --git a/src/components/image_cropper/image_cropper.vue b/src/components/image_cropper/image_cropper.vue new file mode 100644 index 00000000..24a6f3bd --- /dev/null +++ b/src/components/image_cropper/image_cropper.vue @@ -0,0 +1,42 @@ +<template> + <div class="image-cropper"> + <div v-if="dataUrl"> + <div class="image-cropper-image-container"> + <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="destroy" v-text="cancelText"></button> + <i class="icon-spin4 animate-spin" v-if="submitting"></i> + </div> + <div class="alert error" v-if="submitError"> + {{submitErrorMsg}} + <i class="button-icon icon-cancel" @click="clearError"></i> + </div> + </div> + <input ref="input" type="file" class="image-cropper-img-input" :accept="mimes"> + </div> +</template> + +<script src="./image_cropper.js"></script> + +<style lang="scss"> +.image-cropper { + &-img-input { + display: none; + } + + &-image-container { + position: relative; + + img { + display: block; + max-width: 100%; + } + } + + &-buttons-wrapper { + margin-top: 15px; + } +} +</style> diff --git a/src/components/link-preview/link-preview.vue b/src/components/link-preview/link-preview.vue index e4a247c5..64b1a58b 100644 --- a/src/components/link-preview/link-preview.vue +++ b/src/components/link-preview/link-preview.vue @@ -23,10 +23,7 @@ flex-direction: row; cursor: pointer; overflow: hidden; - - // TODO: clean up the random margins in attachments, this makes preview line - // up with attachments... - margin-right: 0.5em; + margin-top: 0.5em; .card-image { flex-shrink: 0; diff --git a/src/components/media_modal/media_modal.js b/src/components/media_modal/media_modal.js index 14ae19d4..992d7129 100644 --- a/src/components/media_modal/media_modal.js +++ b/src/components/media_modal/media_modal.js @@ -11,27 +11,62 @@ const MediaModal = { showing () { return this.$store.state.mediaViewer.activated }, + media () { + return this.$store.state.mediaViewer.media + }, currentIndex () { return this.$store.state.mediaViewer.currentIndex }, currentMedia () { - return this.$store.state.mediaViewer.media[this.currentIndex] + return this.media[this.currentIndex] + }, + canNavigate () { + return this.media.length > 1 }, type () { return this.currentMedia ? fileTypeService.fileType(this.currentMedia.mimetype) : null } }, - created () { - document.addEventListener('keyup', e => { - if (e.keyCode === 27 && this.showing) { // escape - this.hide() - } - }) - }, methods: { hide () { this.$store.dispatch('closeMediaViewer') + }, + goPrev () { + if (this.canNavigate) { + const prevIndex = this.currentIndex === 0 ? this.media.length - 1 : (this.currentIndex - 1) + this.$store.dispatch('setCurrent', this.media[prevIndex]) + } + }, + goNext () { + if (this.canNavigate) { + const nextIndex = this.currentIndex === this.media.length - 1 ? 0 : (this.currentIndex + 1) + this.$store.dispatch('setCurrent', this.media[nextIndex]) + } + }, + handleKeyupEvent (e) { + if (this.showing && e.keyCode === 27) { // escape + this.hide() + } + }, + handleKeydownEvent (e) { + if (!this.showing) { + return + } + + if (e.keyCode === 39) { // arrow right + this.goNext() + } else if (e.keyCode === 37) { // arrow left + this.goPrev() + } } + }, + mounted () { + document.addEventListener('keyup', this.handleKeyupEvent) + document.addEventListener('keydown', this.handleKeydownEvent) + }, + destroyed () { + document.removeEventListener('keyup', this.handleKeyupEvent) + document.removeEventListener('keydown', this.handleKeydownEvent) } } diff --git a/src/components/media_modal/media_modal.vue b/src/components/media_modal/media_modal.vue index 796d4e40..427bf12b 100644 --- a/src/components/media_modal/media_modal.vue +++ b/src/components/media_modal/media_modal.vue @@ -8,6 +8,22 @@ :controls="true" @click.stop.native=""> </VideoAttachment> + <button + :title="$t('media_modal.previous')" + class="modal-view-button-arrow modal-view-button-arrow--prev" + v-if="canNavigate" + @click.stop.prevent="goPrev" + > + <i class="icon-left-open arrow-icon" /> + </button> + <button + :title="$t('media_modal.next')" + class="modal-view-button-arrow modal-view-button-arrow--next" + v-if="canNavigate" + @click.stop.prevent="goNext" + > + <i class="icon-right-open arrow-icon" /> + </button> </div> </template> @@ -19,15 +35,29 @@ .modal-view { z-index: 1000; position: fixed; - width: 100vw; - height: 100vh; top: 0; left: 0; + right: 0; + bottom: 0; display: flex; justify-content: center; align-items: center; background-color: rgba(0, 0, 0, 0.5); - cursor: pointer; + + &:hover { + .modal-view-button-arrow { + opacity: 0.75; + + &:focus, + &:hover { + outline: none; + box-shadow: none; + } + &:hover { + opacity: 1; + } + } + } } .modal-image { @@ -35,4 +65,49 @@ max-height: 90%; box-shadow: 0px 5px 15px 0 rgba(0, 0, 0, 0.5); } + +.modal-view-button-arrow { + position: absolute; + display: block; + top: 50%; + margin-top: -50px; + width: 70px; + height: 100px; + border: 0; + padding: 0; + opacity: 0; + box-shadow: none; + background: none; + appearance: none; + overflow: visible; + cursor: pointer; + transition: opacity 333ms cubic-bezier(.4,0,.22,1); + + .arrow-icon { + position: absolute; + top: 35px; + height: 30px; + width: 32px; + font-size: 14px; + line-height: 30px; + color: #FFF; + text-align: center; + background-color: rgba(0,0,0,.3); + } + + &--prev { + left: 0; + .arrow-icon { + left: 6px; + } + } + + &--next { + right: 0; + .arrow-icon { + right: 6px; + } + } +} + </style> diff --git a/src/components/mute_card/mute_card.js b/src/components/mute_card/mute_card.js new file mode 100644 index 00000000..5dd0a9e5 --- /dev/null +++ b/src/components/mute_card/mute_card.js @@ -0,0 +1,37 @@ +import BasicUserCard from '../basic_user_card/basic_user_card.vue' + +const MuteCard = { + props: ['userId'], + data () { + return { + progress: false + } + }, + computed: { + user () { + return this.$store.getters.userById(this.userId) + }, + muted () { + return this.user.muted + } + }, + components: { + BasicUserCard + }, + methods: { + unmuteUser () { + this.progress = true + this.$store.dispatch('unmuteUser', this.user.id).then(() => { + this.progress = false + }) + }, + muteUser () { + this.progress = true + this.$store.dispatch('muteUser', this.user.id).then(() => { + this.progress = false + }) + } + } +} + +export default MuteCard diff --git a/src/components/mute_card/mute_card.vue b/src/components/mute_card/mute_card.vue new file mode 100644 index 00000000..e1bfe20b --- /dev/null +++ b/src/components/mute_card/mute_card.vue @@ -0,0 +1,24 @@ +<template> + <basic-user-card :user="user"> + <template slot="secondary-area"> + <button class="btn btn-default" @click="unmuteUser" :disabled="progress" v-if="muted"> + <template v-if="progress"> + {{ $t('user_card.unmute_progress') }} + </template> + <template v-else> + {{ $t('user_card.unmute') }} + </template> + </button> + <button class="btn btn-default" @click="muteUser" :disabled="progress" v-else> + <template v-if="progress"> + {{ $t('user_card.mute_progress') }} + </template> + <template v-else> + {{ $t('user_card.mute') }} + </template> + </button> + </template> + </basic-user-card> +</template> + +<script src="./mute_card.js"></script>
\ No newline at end of file diff --git a/src/components/nav_panel/nav_panel.js b/src/components/nav_panel/nav_panel.js index ea5d7ea4..aa3f7605 100644 --- a/src/components/nav_panel/nav_panel.js +++ b/src/components/nav_panel/nav_panel.js @@ -1,10 +1,23 @@ +import followRequestFetcher from '../../services/follow_request_fetcher/follow_request_fetcher.service' + const NavPanel = { + created () { + if (this.currentUser && this.currentUser.locked) { + const store = this.$store + const credentials = store.state.users.currentUser.credentials + + followRequestFetcher.startFetching({ store, credentials }) + } + }, computed: { currentUser () { return this.$store.state.users.currentUser }, chat () { return this.$store.state.chat.channel + }, + followRequestCount () { + return this.$store.state.api.followRequests.length } } } diff --git a/src/components/nav_panel/nav_panel.vue b/src/components/nav_panel/nav_panel.vue index 3aa0a793..7a7212fb 100644 --- a/src/components/nav_panel/nav_panel.vue +++ b/src/components/nav_panel/nav_panel.vue @@ -19,7 +19,10 @@ </li> <li v-if='currentUser && currentUser.locked'> <router-link :to="{ name: 'friend-requests' }"> - {{ $t("nav.friend_requests") }} + {{ $t("nav.friend_requests")}} + <span v-if='followRequestCount > 0' class="badge follow-request-count"> + {{followRequestCount}} + </span> </router-link> </li> <li> @@ -52,6 +55,12 @@ padding: 0; } +.follow-request-count { + margin: -6px 10px; + background-color: $fallback--bg; + background-color: var(--input, $fallback--faint); +} + .nav-panel li { border-bottom: 1px solid; border-color: $fallback--border; diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue index a0a55cba..87925cfc 100644 --- a/src/components/notification/notification.vue +++ b/src/components/notification/notification.vue @@ -25,7 +25,11 @@ <small>{{$t('notifications.followed_you')}}</small> </span> </div> - <small class="timeago"><router-link v-if="notification.status" :to="{ name: 'conversation', params: { id: notification.status.id } }"><timeago :since="notification.action.created_at" :auto-update="240"></timeago></router-link></small> + <div class="timeago"> + <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> + </router-link> + </div> </span> <div class="follow-text" v-if="notification.type === 'follow'"> <router-link :to="userProfileLink(notification.action.user)"> diff --git a/src/components/notifications/notifications.scss b/src/components/notifications/notifications.scss index bc81d45c..2240c10a 100644 --- a/src/components/notifications/notifications.scss +++ b/src/components/notifications/notifications.scss @@ -103,6 +103,7 @@ flex: 1 1 0; display: flex; flex-wrap: nowrap; + justify-content: space-between; .name-and-action { flex: 1; @@ -123,9 +124,9 @@ object-fit: contain } } + .timeago { - float: right; - font-size: 12px; + margin-right: .2em; } .icon-retweet.lit { diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js index 8e30264d..23a2c7e2 100644 --- a/src/components/post_status_form/post_status_form.js +++ b/src/components/post_status_form/post_status_form.js @@ -1,8 +1,8 @@ import statusPoster from '../../services/status_poster/status_poster.service.js' import MediaUpload from '../media_upload/media_upload.vue' -import AutoCompleteInput from '../autocomplete_input/autocomplete_input.vue' import fileTypeService from '../../services/file_type/file_type.service.js' -import { reject, map, uniqBy } from 'lodash' +import Completion from '../../services/completion/completion.js' +import { take, filter, reject, map, uniqBy } from 'lodash' const buildMentionsString = ({user, attentions}, currentUser) => { let allAttentions = [...attentions] @@ -28,10 +28,13 @@ const PostStatusForm = { 'subject' ], components: { - MediaUpload, - AutoCompleteInput + MediaUpload }, mounted () { + this.resize(this.$refs.textarea) + const textLength = this.$refs.textarea.value.length + this.$refs.textarea.setSelectionRange(textLength, textLength) + if (this.replyTo) { this.$refs.textarea.focus() } @@ -53,18 +56,25 @@ const PostStatusForm = { ? this.copyMessageScope : this.$store.state.users.currentUser.default_scope + const contentType = typeof this.$store.state.config.postContentType === 'undefined' + ? this.$store.state.instance.postContentType + : this.$store.state.config.postContentType + return { dropFiles: [], submitDisabled: false, error: null, posting: false, + highlighted: 0, newStatus: { spoilerText: this.subject || '', status: statusText, nsfw: false, files: [], - visibility: scope - } + visibility: scope, + contentType + }, + caret: 0 } }, computed: { @@ -76,6 +86,59 @@ const PostStatusForm = { direct: { selected: this.newStatus.visibility === 'direct' } } }, + candidates () { + const firstchar = this.textAtCaret.charAt(0) + if (firstchar === '@') { + const query = this.textAtCaret.slice(1).toUpperCase() + const matchedUsers = filter(this.users, (user) => { + return user.screen_name.toUpperCase().startsWith(query) || + user.name && user.name.toUpperCase().startsWith(query) + }) + if (matchedUsers.length <= 0) { + return false + } + // eslint-disable-next-line camelcase + return map(take(matchedUsers, 5), ({screen_name, name, profile_image_url_original}, index) => ({ + // eslint-disable-next-line camelcase + screen_name: `@${screen_name}`, + name: name, + img: profile_image_url_original, + highlighted: index === this.highlighted + })) + } else if (firstchar === ':') { + if (this.textAtCaret === ':') { return } + const matchedEmoji = filter(this.emoji.concat(this.customEmoji), (emoji) => emoji.shortcode.startsWith(this.textAtCaret.slice(1))) + if (matchedEmoji.length <= 0) { + return false + } + return map(take(matchedEmoji, 5), ({shortcode, image_url, utf}, index) => ({ + screen_name: `:${shortcode}:`, + name: '', + utf: utf || '', + // eslint-disable-next-line camelcase + img: utf ? '' : this.$store.state.instance.server + image_url, + highlighted: index === this.highlighted + })) + } else { + return false + } + }, + textAtCaret () { + return (this.wordAtCaret || {}).word || '' + }, + wordAtCaret () { + const word = Completion.wordAtPosition(this.newStatus.status, this.caret - 1) || {} + return word + }, + users () { + return this.$store.state.users.users + }, + emoji () { + return this.$store.state.instance.emoji || [] + }, + customEmoji () { + return this.$store.state.instance.customEmoji || [] + }, statusLength () { return this.newStatus.status.length }, @@ -109,15 +172,58 @@ const PostStatusForm = { formattingOptionsEnabled () { return this.$store.state.instance.formattingOptionsEnabled }, - defaultPostContentType () { - return typeof this.$store.state.config.postContentType === 'undefined' - ? this.$store.state.instance.postContentType - : this.$store.state.config.postContentType + postFormats () { + return this.$store.state.instance.postFormats || [] } }, methods: { - postStatusCopy () { - this.postStatus(this.newStatus) + replace (replacement) { + this.newStatus.status = Completion.replaceWord(this.newStatus.status, this.wordAtCaret, replacement) + const el = this.$el.querySelector('textarea') + el.focus() + this.caret = 0 + }, + replaceCandidate (e) { + const len = this.candidates.length || 0 + if (this.textAtCaret === ':' || e.ctrlKey) { return } + if (len > 0) { + e.preventDefault() + const candidate = this.candidates[this.highlighted] + const replacement = candidate.utf || (candidate.screen_name + ' ') + this.newStatus.status = Completion.replaceWord(this.newStatus.status, this.wordAtCaret, replacement) + const el = this.$el.querySelector('textarea') + el.focus() + this.caret = 0 + this.highlighted = 0 + } + }, + cycleBackward (e) { + const len = this.candidates.length || 0 + if (len > 0) { + e.preventDefault() + this.highlighted -= 1 + if (this.highlighted < 0) { + this.highlighted = this.candidates.length - 1 + } + } else { + this.highlighted = 0 + } + }, + cycleForward (e) { + const len = this.candidates.length || 0 + if (len > 0) { + if (e.shiftKey) { return } + e.preventDefault() + this.highlighted += 1 + if (this.highlighted >= len) { + this.highlighted = 0 + } + } else { + this.highlighted = 0 + } + }, + setCaret ({target: {selectionStart}}) { + this.caret = selectionStart }, postStatus (newStatus) { if (this.posting) { return } @@ -202,6 +308,18 @@ const PostStatusForm = { fileDrag (e) { e.dataTransfer.dropEffect = 'copy' }, + resize (e) { + const target = e.target || e + if (!(target instanceof window.Element)) { return } + const vertPadding = Number(window.getComputedStyle(target)['padding-top'].substr(0, 1)) + + Number(window.getComputedStyle(target)['padding-bottom'].substr(0, 1)) + // Auto is needed to make textbox shrink when removing lines + target.style.height = 'auto' + target.style.height = `${target.scrollHeight - vertPadding}px` + if (target.value === '') { + target.style.height = null + } + }, clearError () { this.error = null }, diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue index ef3a7901..0ddde4ea 100644 --- a/src/components/post_status_form/post_status_form.vue +++ b/src/components/post_status_form/post_status_form.vue @@ -16,23 +16,31 @@ :placeholder="$t('post_status.content_warning')" v-model="newStatus.spoilerText" class="form-cw"> - <auto-complete-input v-model="newStatus.status" - :classObj="{ 'form-control': true }" - :placeholder="$t('post_status.default')" - :autoResize="true" - :multiline="true" - :drop="fileDrop" - :dragoverPrevent="fileDrag" - :paste="paste" - :keydownMetaEnter="postStatusCopy" - :keyupCtrlEnter="postStatusCopy"/> + <textarea + ref="textarea" + @click="setCaret" + @keyup="setCaret" v-model="newStatus.status" :placeholder="$t('post_status.default')" rows="1" class="form-control" + @keydown.down="cycleForward" + @keydown.up="cycleBackward" + @keydown.shift.tab="cycleBackward" + @keydown.tab="cycleForward" + @keydown.enter="replaceCandidate" + @keydown.meta.enter="postStatus(newStatus)" + @keyup.ctrl.enter="postStatus(newStatus)" + @drop="fileDrop" + @dragover.prevent="fileDrag" + @input="resize" + @paste="paste" + :disabled="posting" + > + </textarea> <div class="visibility-tray"> <span class="text-format" v-if="formattingOptionsEnabled"> <label for="post-content-type" class="select"> - <select id="post-content-type" v-model="defaultPostContentType" class="form-control"> - <option value="text/plain">{{$t('post_status.content_type.plain_text')}}</option> - <option value="text/html">HTML</option> - <option value="text/markdown">Markdown</option> + <select id="post-content-type" v-model="newStatus.contentType" class="form-control"> + <option v-for="postFormat in postFormats" :key="postFormat" :value="postFormat"> + {{$t(`post_status.content_type["${postFormat}"]`)}} + </option> </select> <i class="icon-down-open"></i> </label> @@ -46,6 +54,17 @@ </div> </div> </div> + <div style="position:relative;" v-if="candidates"> + <div class="autocomplete-panel"> + <div v-for="candidate in candidates" @click="replace(candidate.utf || (candidate.screen_name + ' '))"> + <div class="autocomplete" :class="{ highlighted: candidate.highlighted }"> + <span v-if="candidate.img"><img :src="candidate.img"></img></span> + <span v-else>{{candidate.utf}}</span> + <span>{{candidate.screen_name}}<small>{{candidate.name}}</small></span> + </div> + </div> + </div> + </div> <div class='form-bottom'> <media-upload ref="mediaUpload" @uploading="disableSubmit" @uploaded="addMediaFile" @upload-failed="uploadFailed" :drop-files="dropFiles"></media-upload> @@ -101,6 +120,14 @@ } } +.post-status-form { + .visibility-tray { + display: flex; + justify-content: space-between; + flex-direction: row-reverse; + } +} + .post-status-form, .login { .form-bottom { display: flex; @@ -233,5 +260,52 @@ cursor: pointer; z-index: 4; } + + .autocomplete-panel { + margin: 0 0.5em 0 0.5em; + border-radius: $fallback--tooltipRadius; + border-radius: var(--tooltipRadius, $fallback--tooltipRadius); + position: absolute; + z-index: 1; + box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5); + // this doesn't match original but i don't care, making it uniform. + box-shadow: var(--popupShadow); + min-width: 75%; + background: $fallback--bg; + background: var(--bg, $fallback--bg); + color: $fallback--lightText; + color: var(--lightText, $fallback--lightText); + } + + .autocomplete { + cursor: pointer; + padding: 0.2em 0.4em 0.2em 0.4em; + border-bottom: 1px solid rgba(0, 0, 0, 0.4); + display: flex; + + img { + width: 24px; + height: 24px; + border-radius: $fallback--avatarRadius; + border-radius: var(--avatarRadius, $fallback--avatarRadius); + object-fit: contain; + } + + span { + line-height: 24px; + margin: 0 0.1em 0 0.2em; + } + + small { + margin-left: .5em; + color: $fallback--faint; + color: var(--faint, $fallback--faint); + } + + &.highlighted { + background-color: $fallback--fg; + background-color: var(--lightBg, $fallback--fg); + } + } } </style> diff --git a/src/components/registration/registration.vue b/src/components/registration/registration.vue index f428ead3..e22b308d 100644 --- a/src/components/registration/registration.vue +++ b/src/components/registration/registration.vue @@ -9,7 +9,7 @@ <div class='text-fields'> <div class='form-group' :class="{ 'form-group--error': $v.user.username.$error }"> <label class='form--label' for='sign-up-username'>{{$t('login.username')}}</label> - <input :disabled="isPending" v-model.trim='$v.user.username.$model' class='form-control' id='sign-up-username' placeholder='e.g. lain'> + <input :disabled="isPending" v-model.trim='$v.user.username.$model' class='form-control' id='sign-up-username' :placeholder="$t('registration.username_placeholder')"> </div> <div class="form-error" v-if="$v.user.username.$dirty"> <ul> @@ -21,7 +21,7 @@ <div class='form-group' :class="{ 'form-group--error': $v.user.fullname.$error }"> <label class='form--label' for='sign-up-fullname'>{{$t('registration.fullname')}}</label> - <input :disabled="isPending" v-model.trim='$v.user.fullname.$model' class='form-control' id='sign-up-fullname' placeholder='e.g. Lain Iwakura'> + <input :disabled="isPending" v-model.trim='$v.user.fullname.$model' class='form-control' id='sign-up-fullname' :placeholder="$t('registration.fullname_placeholder')"> </div> <div class="form-error" v-if="$v.user.fullname.$dirty"> <ul> @@ -44,8 +44,8 @@ </div> <div class='form-group'> - <label class='form--label' for='bio'>{{$t('registration.bio')}}</label> - <input :disabled="isPending" v-model='user.bio' class='form-control' id='bio'> + <label class='form--label' for='bio'>{{$t('registration.bio')}} ({{$t('general.optional')}})</label> + <textarea :disabled="isPending" v-model='user.bio' class='form-control' id='bio' :placeholder="$t('registration.bio_placeholder')"></textarea> </div> <div class='form-group' :class="{ 'form-group--error': $v.user.password.$error }"> @@ -139,6 +139,10 @@ $validations-cRed: #f04124; flex-direction: column; } + textarea { + min-height: 100px; + } + .form-group { display: flex; flex-direction: column; diff --git a/src/components/settings/settings.js b/src/components/settings/settings.js index 534a9839..979457a5 100644 --- a/src/components/settings/settings.js +++ b/src/components/settings/settings.js @@ -12,6 +12,7 @@ const settings = { return { hideAttachmentsLocal: user.hideAttachments, hideAttachmentsInConvLocal: user.hideAttachmentsInConv, + maxThumbnails: user.maxThumbnails, hideNsfwLocal: user.hideNsfw, useOneClickNsfw: user.useOneClickNsfw, hideISPLocal: user.hideISP, @@ -91,7 +92,11 @@ const settings = { }, currentSaveStateNotice () { return this.$store.state.interface.settings.currentSaveStateNotice - } + }, + postFormats () { + return this.$store.state.instance.postFormats || [] + }, + instanceSpecificPanelPresent () { return this.$store.state.instance.showInstanceSpecificPanel } }, watch: { hideAttachmentsLocal (value) { @@ -185,6 +190,10 @@ const settings = { }, useContainFit (value) { this.$store.dispatch('setOption', { name: 'useContainFit', value }) + }, + maxThumbnails (value) { + value = this.maxThumbnails = Math.floor(Math.max(value, 0)) + this.$store.dispatch('setOption', { name: 'maxThumbnails', value }) } } } diff --git a/src/components/settings/settings.vue b/src/components/settings/settings.vue index dfb2e49d..d2346747 100644 --- a/src/components/settings/settings.vue +++ b/src/components/settings/settings.vue @@ -27,7 +27,7 @@ <li> <interface-language-switcher /> </li> - <li> + <li v-if="instanceSpecificPanelPresent"> <input type="checkbox" id="hideISP" v-model="hideISPLocal"> <label for="hideISP">{{$t('settings.hide_isp')}}</label> </li> @@ -105,17 +105,9 @@ {{$t('settings.post_status_content_type')}} <label for="postContentType" class="select"> <select id="postContentType" v-model="postContentTypeLocal"> - <option value="text/plain"> - {{$t('settings.status_content_type_plain')}} - {{postContentTypeDefault == 'text/plain' ? $t('settings.instance_default_simple') : ''}} - </option> - <option value="text/html"> - HTML - {{postContentTypeDefault == 'text/html' ? $t('settings.instance_default_simple') : ''}} - </option> - <option value="text/markdown"> - Markdown - {{postContentTypeDefault == 'text/markdown' ? $t('settings.instance_default_simple') : ''}} + <option v-for="postFormat in postFormats" :key="postFormat" :value="postFormat"> + {{$t(`post_status.content_type["${postFormat}"]`)}} + {{postContentTypeDefault === postFormat ? $t('settings.instance_default_simple') : ''}} </option> </select> <i class="icon-down-open"/> @@ -137,6 +129,10 @@ <label for="hideAttachmentsInConv">{{$t('settings.hide_attachments_in_convo')}}</label> </li> <li> + <label for="maxThumbnails">{{$t('settings.max_thumbnails')}}</label> + <input class="number-input" type="number" id="maxThumbnails" v-model.number="maxThumbnails" min="0" step="1"> + </li> + <li> <input type="checkbox" id="hideNsfw" v-model="hideNsfwLocal"> <label for="hideNsfw">{{$t('settings.nsfw_clickthrough')}}</label> </li> @@ -146,7 +142,7 @@ <label for="preloadImage">{{$t('settings.preload_images')}}</label> </li> <li> - <input type="checkbox" id="useOneClickNsfw" v-model="useOneClickNsfw"> + <input :disabled="!hideNsfwLocal" type="checkbox" id="useOneClickNsfw" v-model="useOneClickNsfw"> <label for="useOneClickNsfw">{{$t('settings.use_one_click_nsfw')}}</label> </li> </ul> @@ -311,25 +307,15 @@ color: $fallback--cRed; } - .old-avatar { - width: 128px; - border-radius: $fallback--avatarRadius; - border-radius: var(--avatarRadius, $fallback--avatarRadius); - } - - .new-avatar { - object-fit: cover; - width: 128px; - height: 128px; - border-radius: $fallback--avatarRadius; - border-radius: var(--avatarRadius, $fallback--avatarRadius); - } - .btn { min-height: 28px; min-width: 10em; padding: 0 2em; } + + .number-input { + max-width: 6em; + } } .select-multiple { display: flex; diff --git a/src/components/side_drawer/side_drawer.js b/src/components/side_drawer/side_drawer.js index 40ffa1dd..b5c49059 100644 --- a/src/components/side_drawer/side_drawer.js +++ b/src/components/side_drawer/side_drawer.js @@ -32,6 +32,9 @@ const SideDrawer = { }, sitename () { return this.$store.state.instance.name + }, + followRequestCount () { + return this.$store.state.api.followRequests.length } }, methods: { diff --git a/src/components/side_drawer/side_drawer.vue b/src/components/side_drawer/side_drawer.vue index a6c6f237..6996380d 100644 --- a/src/components/side_drawer/side_drawer.vue +++ b/src/components/side_drawer/side_drawer.vue @@ -45,6 +45,10 @@ <li v-if="currentUser && currentUser.locked" @click="toggleDrawer"> <router-link to='/friend-requests'> {{ $t("nav.friend_requests") }} + <span v-if='followRequestCount > 0' class="badge follow-request-count"> + {{followRequestCount}} + </span> + </router-link> </li> <li @click="toggleDrawer"> diff --git a/src/components/status/status.js b/src/components/status/status.js index 0273a5be..fbbca6c4 100644 --- a/src/components/status/status.js +++ b/src/components/status/status.js @@ -23,7 +23,7 @@ const Status = { 'highlight', 'compact', 'replies', - 'noReplyLinks', + 'isPreview', 'noHeading', 'inlineExpanded' ], @@ -40,8 +40,7 @@ const Status = { expandingSubject: typeof this.$store.state.config.collapseMessageWithSubject === 'undefined' ? !this.$store.state.instance.collapseMessageWithSubject : !this.$store.state.config.collapseMessageWithSubject, - betterShadow: this.$store.state.interface.browserSupport.cssFilter, - maxAttachments: 9 + betterShadow: this.$store.state.interface.browserSupport.cssFilter } }, computed: { @@ -225,7 +224,7 @@ const Status = { attachmentSize () { if ((this.$store.state.config.hideAttachments && !this.inConversation) || (this.$store.state.config.hideAttachmentsInConv && this.inConversation) || - (this.status.attachments.length > this.maxAttachments)) { + (this.status.attachments.length > this.maxThumbnails)) { return 'hide' } else if (this.compact) { return 'small' @@ -249,6 +248,9 @@ const Status = { return this.status.attachments.filter( file => !fileType.fileMatchesSomeType(this.galleryTypes, file) ) + }, + maxThumbnails () { + return this.$store.state.config.maxThumbnails } }, components: { diff --git a/src/components/status/status.vue b/src/components/status/status.vue index aae365d1..4dd20362 100644 --- a/src/components/status/status.vue +++ b/src/components/status/status.vue @@ -1,6 +1,6 @@ <template> <div class="status-el" v-if="!hideStatus" :class="[{ 'status-el_focused': isFocused }, { 'status-conversation': inlineExpanded }]"> - <template v-if="muted && !noReplyLinks"> + <template v-if="muted && !isPreview"> <div class="media status container muted"> <small> <router-link :to="userProfileLink"> @@ -13,7 +13,7 @@ </template> <template v-else> <div v-if="retweet && !noHeading" :class="[repeaterClass, { highlighted: repeaterStyle }]" :style="[repeaterStyle]" class="media container retweet-info"> - <UserAvatar v-if="retweet" :betterShadow="betterShadow" :src="statusoid.user.profile_image_url_original"/> + <UserAvatar class="media-left" v-if="retweet" :betterShadow="betterShadow" :src="statusoid.user.profile_image_url_original"/> <div class="media-body faint"> <span class="user-name"> <router-link v-if="retweeterHtml" :to="retweeterProfileLink" v-html="retweeterHtml"/> @@ -31,57 +31,69 @@ </router-link> </div> <div class="status-body"> - <div class="usercard media-body" v-if="userExpanded"> + <div class="usercard" v-if="userExpanded"> <user-card-content :user="status.user" :switcher="false"></user-card-content> </div> - <div v-if="!noHeading" class="media-body container media-heading"> - <div class="media-heading-left"> - <div class="name-and-links"> + <div v-if="!noHeading" class="media-heading"> + <div class="heading-name-row"> + <div class="name-and-account-name"> <h4 class="user-name" v-if="status.user.name_html" v-html="status.user.name_html"></h4> <h4 class="user-name" v-else>{{status.user.name}}</h4> - <span class="links"> - <router-link :to="userProfileLink"> - {{status.user.screen_name}} - </router-link> - <span v-if="isReply" class="faint reply-info"> - <i class="icon-right-open"></i> - <router-link :to="replyProfileLink"> - {{replyToName}} - </router-link> - </span> - <a v-if="isReply && !noReplyLinks" href="#" @click.prevent="gotoOriginal(status.in_reply_to_status_id)" :aria-label="$t('tool_tip.reply')"> - <i class="button-icon icon-reply" @mouseenter="replyEnter(status.in_reply_to_status_id, $event)" @mouseout="replyLeave()"></i> + <router-link class="account-name" :to="userProfileLink"> + {{status.user.screen_name}} + </router-link> + </div> + + <span class="heading-right"> + <router-link class="timeago faint-link" :to="{ name: 'conversation', params: { id: status.id } }"> + <timeago :since="status.created_at" :auto-update="60"></timeago> + </router-link> + <div class="button-icon visibility-icon" v-if="status.visibility"> + <i :class="visibilityIcon(status.visibility)" :title="status.visibility | capitalize"></i> + </div> + <a :href="status.external_url" target="_blank" v-if="!status.is_local && !isPreview" class="source_url" title="Source"> + <i class="button-icon icon-link-ext-alt"></i> + </a> + <template v-if="expandable && !isPreview"> + <a href="#" @click.prevent="toggleExpanded" title="Expand"> + <i class="button-icon icon-plus-squared"></i> </a> + </template> + <a href="#" @click.prevent="toggleMute" v-if="unmuted"><i class="button-icon icon-eye-off"></i></a> + </span> + </div> + + <div class="heading-reply-row"> + <div v-if="isReply" class="reply-to-and-accountname"> + <a class="reply-to" + href="#" @click.prevent="gotoOriginal(status.in_reply_to_status_id)" + :aria-label="$t('tool_tip.reply')" + @mouseenter.prevent.stop="replyEnter(status.in_reply_to_status_id, $event)" + @mouseleave.prevent.stop="replyLeave()" + > + <i class="button-icon icon-reply" v-if="!isPreview"></i> + <span class="faint-link reply-to-text">{{$t('status.reply_to')}}</span> + </a> + <router-link :to="replyProfileLink"> + {{replyToName}} + </router-link> + <span class="faint replies-separator" v-if="replies && replies.length"> + - </span> </div> - <h4 class="replies" v-if="inConversation && !noReplyLinks"> - <small v-if="replies.length">Replies:</small> - <small class="reply-link" v-bind:key="reply.id" v-for="reply in replies"> - <a href="#" @click.prevent="gotoOriginal(reply.id)" @mouseenter="replyEnter(reply.id, $event)" @mouseout="replyLeave()">{{reply.name}} </a> - </small> - </h4> - </div> - <div class="media-heading-right"> - <router-link class="timeago" :to="{ name: 'conversation', params: { id: status.id } }"> - <timeago :since="status.created_at" :auto-update="60"></timeago> - </router-link> - <div class="button-icon visibility-icon" v-if="status.visibility"> - <i :class="visibilityIcon(status.visibility)" :title="status.visibility | capitalize"></i> + <div class="replies" v-if="inConversation && !isPreview"> + <span class="faint" v-if="replies && replies.length">{{$t('status.replies_list')}}</span> + <span class="reply-link faint" v-if="replies" v-for="reply in replies"> + <a href="#" @click.prevent="gotoOriginal(reply.id)" @mouseenter="replyEnter(reply.id, $event)" @mouseout="replyLeave()">{{reply.name}}</a> + </span> </div> - <a :href="status.external_url" target="_blank" v-if="!status.is_local" class="source_url" title="Source"> - <i class="button-icon icon-link-ext-alt"></i> - </a> - <template v-if="expandable"> - <a href="#" @click.prevent="toggleExpanded" title="Expand"> - <i class="button-icon icon-plus-squared"></i> - </a> - </template> - <a href="#" @click.prevent="toggleMute" v-if="unmuted"><i class="button-icon icon-eye-off"></i></a> </div> + + </div> <div v-if="showPreview" class="status-preview-container"> - <status class="status-preview" v-if="preview" :noReplyLinks="true" :statusoid="preview" :compact=true></status> + <status class="status-preview" v-if="preview" :isPreview="true" :statusoid="preview" :compact=true></status> <div class="status-preview status-preview-loading" v-else> <i class="icon-spin4 animate-spin"></i> </div> @@ -123,7 +135,7 @@ <link-preview :card="status.card" :size="attachmentSize" :nsfw="nsfwClickthrough" /> </div> - <div v-if="!noHeading && !noReplyLinks" class='status-actions media-body'> + <div v-if="!noHeading && !isPreview" class='status-actions media-body'> <div v-if="loggedIn"> <a href="#" v-on:click.prevent="toggleReplying" :title="$t('tool_tip.reply')"> <i class="button-icon icon-reply" :class="{'icon-reply-active': replying}"></i> @@ -147,6 +159,8 @@ <style lang="scss"> @import '../../_variables.scss'; +$status-margin: 0.75em; + .status-body { flex: 1; min-width: 0; @@ -202,13 +216,16 @@ } } +.media-left { + margin-right: $status-margin; +} + .status-el { hyphens: auto; overflow-wrap: break-word; word-wrap: break-word; word-break: break-word; border-left-width: 0px; - line-height: 18px; min-width: 0; border-color: $fallback--border; border-color: var(--border, $fallback--border); @@ -229,22 +246,34 @@ .media-body { flex: 1; padding: 0; - margin: 0 0 0.25em 0.8em; } .usercard { - margin-bottom: .7em + margin: 0; + margin-bottom: $status-margin; } - .media-heading { - flex-wrap: nowrap; - line-height: 18px; + .user-name { + white-space: nowrap; + font-size: 14px; + overflow: hidden; + flex-shrink: 0; + max-width: 85%; + font-weight: bold; + + img { + width: 14px; + height: 14px; + vertical-align: middle; + object-fit: contain + } } - .media-heading-left { + .media-heading { padding: 0; vertical-align: bottom; flex-basis: 100%; + margin-bottom: 0.5em; a { display: inline-block; @@ -254,83 +283,102 @@ small { font-weight: lighter; } - h4 { - white-space: nowrap; - font-size: 14px; - margin-right: 0.25em; - overflow: hidden; - text-overflow: ellipsis; - } - .name-and-links { + + .heading-name-row { padding: 0; - flex: 1 0; display: flex; - flex-wrap: wrap; - align-items: baseline; + justify-content: space-between; + line-height: 18px; + + .name-and-account-name { + display: flex; + min-width: 0; + } .user-name { - margin-right: .45em; + flex-shrink: 1; + margin-right: 0.4em; + overflow: hidden; + text-overflow: ellipsis; + } - img { - width: 14px; - height: 14px; - vertical-align: middle; - object-fit: contain - } + .account-name { + min-width: 1.6em; + margin-right: 0.4em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1 1 0; } } - .links { + .heading-right { display: flex; + flex-shrink: 0; + } + + .timeago { + margin-right: 0.2em; + } + + .heading-reply-row { + align-content: baseline; font-size: 12px; - color: $fallback--link; - color: var(--link, $fallback--link); + line-height: 18px; max-width: 100%; + display: flex; + flex-wrap: wrap; + align-items: stretch; + a { max-width: 100%; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; } - & > span { - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - } - & > a:last-child { - flex-shrink: 0; + } + + .reply-to-and-accountname { + display: flex; + height: 18px; + margin-right: 0.5em; + overflow: hidden; + max-width: 100%; + .icon-reply { + transform: scaleX(-1); } } + .reply-info { display: flex; } - .replies { - line-height: 16px; + + .reply-to { + display: flex; } - .reply-link { - margin-right: 0.2em; + + .reply-to-text { + overflow: hidden; + text-overflow: ellipsis; + margin: 0 0.4em 0 0.2em; } - } - .media-heading-right { - display: inline-flex; - flex-shrink: 0; - flex-wrap: nowrap; - margin-left: .25em; - align-self: baseline; + .replies-separator { + margin-left: 0.4em; + } - .timeago { - margin-right: 0.2em; + .replies { + line-height: 18px; font-size: 12px; - align-self: last baseline; + display: flex; + flex-wrap: wrap; + & > * { + margin-right: 0.4em; + } } - > * { - margin-left: 0.2em; - } - a:hover i { - color: $fallback--text; - color: var(--text, $fallback--text); + .reply-link { + height: 17px; } } @@ -366,14 +414,19 @@ } .status-content { - margin-right: 0.5em; font-family: var(--postFont, sans-serif); + line-height: 1.4em; img, video { max-width: 100%; max-height: 400px; vertical-align: middle; object-fit: contain; + + &.emoji { + width: 32px; + height: 32px; + } } blockquote { @@ -390,9 +443,11 @@ } p { - margin: 0; - margin-top: 0.2em; - margin-bottom: 0.5em; + margin: 0 0 1em 0; + } + + p:last-child { + margin: 0 0 0 0; } h1 { @@ -417,7 +472,7 @@ } .retweet-info { - padding: 0.4em 0.6em 0 0.6em; + padding: 0.4em $status-margin; margin: 0; .avatar.still-image { @@ -488,10 +543,10 @@ .status-actions { width: 100%; display: flex; + margin-top: $status-margin; div, favorite-button { - padding-top: 0.25em; - max-width: 6em; + max-width: 4em; flex: 1; } } @@ -517,9 +572,9 @@ .status { display: flex; - padding: 0.6em; + padding: $status-margin; &.is-retweet { - padding-top: 0.1em; + padding-top: 0; } } @@ -554,7 +609,7 @@ a.unmute { .timeline > { .status-el:last-child { - border-bottom-radius: 0 0 $fallback--panelRadius $fallback--panelRadius;; + border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius; border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius); border-bottom: none; } diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js index 85e0a055..655bfb3f 100644 --- a/src/components/timeline/timeline.js +++ b/src/components/timeline/timeline.js @@ -1,7 +1,6 @@ import Status from '../status/status.vue' import timelineFetcher from '../../services/timeline_fetcher/timeline_fetcher.service.js' import StatusOrConversation from '../status_or_conversation/status_or_conversation.vue' -import UserCard from '../user_card/user_card.vue' import { throttle } from 'lodash' const Timeline = { @@ -11,7 +10,8 @@ const Timeline = { 'title', 'userId', 'tag', - 'embedded' + 'embedded', + 'count' ], data () { return { @@ -43,8 +43,7 @@ const Timeline = { }, components: { Status, - StatusOrConversation, - UserCard + StatusOrConversation }, created () { const store = this.$store @@ -53,6 +52,8 @@ const Timeline = { window.addEventListener('scroll', this.scrollLoad) + if (this.timelineName === 'friends' && !credentials) { return false } + timelineFetcher.fetchAndUpdate({ store, credentials, @@ -67,14 +68,21 @@ const Timeline = { document.addEventListener('visibilitychange', this.handleVisibilityChange, false) this.unfocused = document.hidden } + window.addEventListener('keydown', this.handleShortKey) }, destroyed () { window.removeEventListener('scroll', this.scrollLoad) + window.removeEventListener('keydown', this.handleShortKey) if (typeof document.hidden !== 'undefined') document.removeEventListener('visibilitychange', this.handleVisibilityChange, false) this.$store.commit('setLoading', { timeline: this.timelineName, value: false }) }, methods: { + handleShortKey (e) { + if (e.key === '.') this.showNewStatuses() + }, showNewStatuses () { + if (this.newStatusCount === 0) return + if (this.timeline.flushMarker !== 0) { this.$store.commit('clearTimeline', { timeline: this.timelineName }) this.$store.commit('queueFlush', { timeline: this.timelineName, id: 0 }) @@ -98,7 +106,7 @@ const Timeline = { tag: this.tag }).then(statuses => { store.commit('setLoading', { timeline: this.timelineName, value: false }) - if (statuses.length === 0) { + if (statuses && statuses.length === 0) { this.bottomedOut = true } }) diff --git a/src/components/timeline/timeline.vue b/src/components/timeline/timeline.vue index e3eea3bd..8f28d65c 100644 --- a/src/components/timeline/timeline.vue +++ b/src/components/timeline/timeline.vue @@ -20,7 +20,10 @@ </div> </div> <div :class="classes.footer"> - <div v-if="bottomedOut" class="new-status-notification text-center panel-footer faint"> + <div v-if="count===0" class="new-status-notification text-center panel-footer faint"> + {{$t('timeline.no_statuses')}} + </div> + <div v-else-if="bottomedOut" class="new-status-notification text-center panel-footer faint"> {{$t('timeline.no_more_statuses')}} </div> <a v-else-if="!timeline.loading" href="#" v-on:click.prevent='fetchOlderStatuses()'> diff --git a/src/components/user_card/user_card.js b/src/components/user_card/user_card.js deleted file mode 100644 index a4c84716..00000000 --- a/src/components/user_card/user_card.js +++ /dev/null @@ -1,65 +0,0 @@ -import UserCardContent from '../user_card_content/user_card_content.vue' -import UserAvatar from '../user_avatar/user_avatar.vue' -import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' -import { requestFollow, requestUnfollow } from '../../services/follow_manipulate/follow_manipulate' - -const UserCard = { - props: [ - 'user', - 'showFollows', - 'showApproval', - 'showActions' - ], - data () { - return { - userExpanded: false, - followRequestInProgress: false, - followRequestSent: false, - updated: false - } - }, - components: { - UserCardContent, - UserAvatar - }, - computed: { - currentUser () { return this.$store.state.users.currentUser }, - following () { return this.updated ? this.updated.following : this.user.following }, - showFollow () { - return this.showActions && (!this.showFollows && !this.following || this.updated && !this.updated.following) - } - }, - methods: { - toggleUserExpanded () { - this.userExpanded = !this.userExpanded - }, - approveUser () { - this.$store.state.api.backendInteractor.approveUser(this.user.id) - this.$store.dispatch('removeFollowRequest', this.user) - }, - denyUser () { - this.$store.state.api.backendInteractor.denyUser(this.user.id) - this.$store.dispatch('removeFollowRequest', this.user) - }, - userProfileLink (user) { - return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames) - }, - followUser () { - this.followRequestInProgress = true - requestFollow(this.user, this.$store).then(({ sent, updated }) => { - this.followRequestInProgress = false - this.followRequestSent = sent - this.updated = updated - }) - }, - unfollowUser () { - this.followRequestInProgress = true - requestUnfollow(this.user, this.$store).then(({ updated }) => { - this.followRequestInProgress = false - this.updated = updated - }) - } - } -} - -export default UserCard diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue deleted file mode 100644 index 12960c02..00000000 --- a/src/components/user_card/user_card.vue +++ /dev/null @@ -1,137 +0,0 @@ -<template> - <div class="card"> - <router-link :to="userProfileLink(user)"> - <UserAvatar class="avatar" :compact="true" @click.prevent.native="toggleUserExpanded" :src="user.profile_image_url"/> - </router-link> - <div class="usercard" v-if="userExpanded"> - <user-card-content :user="user" :switcher="false"></user-card-content> - </div> - <div class="name-and-screen-name" v-else> - <div :title="user.name" class="user-name"> - <span v-if="user.name_html" v-html="user.name_html"></span> - <span v-else>{{ user.name }}</span> - <span class="follows-you" v-if="!userExpanded && showFollows && user.follows_you"> - {{ currentUser.id == user.id ? $t('user_card.its_you') : $t('user_card.follows_you') }} - </span> - </div> - <div class="user-link-action"> - <router-link class='user-screen-name' :to="userProfileLink(user)"> - @{{user.screen_name}} - </router-link> - <button - v-if="showFollow" - class="btn btn-default" - @click="followUser" - :disabled="followRequestInProgress" - :title="followRequestSent ? $t('user_card.follow_again') : ''" - > - <template v-if="followRequestInProgress"> - {{ $t('user_card.follow_progress') }} - </template> - <template v-else-if="followRequestSent"> - {{ $t('user_card.follow_sent') }} - </template> - <template v-else> - {{ $t('user_card.follow') }} - </template> - </button> - <button v-if="showActions && showFollows && following" class="btn btn-default" @click="unfollowUser" :disabled="followRequestInProgress"> - <template v-if="followRequestInProgress"> - {{ $t('user_card.follow_progress') }} - </template> - <template v-else> - {{ $t('user_card.follow_unfollow') }} - </template> - </button> - </div> - </div> - <div class="approval" v-if="showApproval"> - <button class="btn btn-default" @click="approveUser">{{ $t('user_card.approve') }}</button> - <button class="btn btn-default" @click="denyUser">{{ $t('user_card.deny') }}</button> - </div> - </div> -</template> - -<script src="./user_card.js"></script> - -<style lang="scss"> -@import '../../_variables.scss'; - -.name-and-screen-name { - margin-left: 0.7em; - margin-top:0.0em; - text-align: left; - width: 100%; - .user-name { - display: flex; - justify-content: space-between; - - img { - object-fit: contain; - height: 16px; - width: 16px; - vertical-align: middle; - } - } - - .user-link-action { - display: flex; - align-items: flex-start; - justify-content: space-between; - - button { - margin-top: 3px; - } - } -} - -.follows-you { - margin-left: 2em; -} - -.card { - display: flex; - flex: 1 0; - padding-top: 0.6em; - padding-right: 1em; - padding-bottom: 0.6em; - padding-left: 1em; - border-bottom: 1px solid; - margin: 0; - border-bottom-color: $fallback--border; - border-bottom-color: var(--border, $fallback--border); - - .avatar { - padding: 0; - } -} - -.usercard { - width: fill-available; - margin: 0.2em 0 0 0.7em; - border-radius: $fallback--panelRadius; - border-radius: var(--panelRadius, $fallback--panelRadius); - border-style: solid; - border-color: $fallback--border; - border-color: var(--border, $fallback--border); - border-width: 1px; - overflow: hidden; - - .panel-heading { - background: transparent; - flex-direction: column; - align-items: stretch; - } - - p { - margin-bottom: 0; - } -} - -.approval { - button { - width: 100%; - margin-bottom: 0.5em; - } -} -</style> diff --git a/src/components/user_card_content/user_card_content.vue b/src/components/user_card_content/user_card_content.vue index 7f9909c4..702c3385 100644 --- a/src/components/user_card_content/user_card_content.vue +++ b/src/components/user_card_content/user_card_content.vue @@ -13,7 +13,7 @@ <router-link :to="{ name: 'user-settings' }" v-if="!isOtherUser"> <i class="button-icon icon-cog usersettings" :title="$t('tool_tip.user_settings')"></i> </router-link> - <a :href="user.statusnet_profile_url" target="_blank" v-if="isOtherUser"> + <a :href="user.statusnet_profile_url" target="_blank" v-if="isOtherUser && !user.is_local"> <i class="icon-link-ext usersettings"></i> </a> </div> @@ -222,6 +222,14 @@ overflow: hidden; flex: 1 1 auto; margin-right: 1em; + font-size: 15px; + + img { + object-fit: contain; + height: 16px; + width: 16px; + vertical-align: middle; + } } .user-screen-name { @@ -386,6 +394,24 @@ } } -.floater { +.usercard { + width: fill-available; + border-radius: $fallback--panelRadius; + border-radius: var(--panelRadius, $fallback--panelRadius); + border-style: solid; + border-color: $fallback--border; + border-color: var(--border, $fallback--border); + border-width: 1px; + overflow: hidden; + + .panel-heading { + background: transparent; + flex-direction: column; + align-items: stretch; + } + + p { + margin-bottom: 0; + } } </style> diff --git a/src/components/user_finder/user_finder.js b/src/components/user_finder/user_finder.js index 55c6c402..27153f45 100644 --- a/src/components/user_finder/user_finder.js +++ b/src/components/user_finder/user_finder.js @@ -8,6 +8,7 @@ const UserFinder = { methods: { findUser (username) { this.$router.push({ name: 'user-search', query: { query: username } }) + this.$refs.userSearchInput.focus() }, toggleHidden () { this.hidden = !this.hidden diff --git a/src/components/user_finder/user_finder.vue b/src/components/user_finder/user_finder.vue index 37d628fa..a118ffe2 100644 --- a/src/components/user_finder/user_finder.vue +++ b/src/components/user_finder/user_finder.vue @@ -4,7 +4,7 @@ <i class="icon-spin4 user-finder-icon animate-spin-slow" v-if="loading" /> <a href="#" v-if="hidden" :title="$t('finder.find_user')"><i class="icon-user-plus user-finder-icon" @click.prevent.stop="toggleHidden" /></a> <template v-else> - <input class="user-finder-input" @keyup.enter="findUser(username)" v-model="username" :placeholder="$t('finder.find_user')" id="user-finder-input" type="text"/> + <input class="user-finder-input" ref="userSearchInput" @keyup.enter="findUser(username)" v-model="username" :placeholder="$t('finder.find_user')" id="user-finder-input" type="text"/> <button class="btn search-button" @click="findUser(username)"> <i class="icon-search"/> </button> diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js index a22b8722..cdf1cee9 100644 --- a/src/components/user_profile/user_profile.js +++ b/src/components/user_profile/user_profile.js @@ -1,9 +1,39 @@ +import { compose } from 'vue-compose' +import get from 'lodash/get' import UserCardContent from '../user_card_content/user_card_content.vue' -import UserCard from '../user_card/user_card.vue' +import FollowCard from '../follow_card/follow_card.vue' import Timeline from '../timeline/timeline.vue' -import FollowList from '../follow_list/follow_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.userById(props.userId), 'followers', []), + destory: (props, $store) => $store.dispatch('clearFollowers', props.userId), + childPropName: 'entries', + additionalPropNames: ['userId'] + }), + withList({ getEntryProps: user => ({ user }) }) +)(FollowCard) + +const FriendList = compose( + withLoadMore({ + fetch: (props, $store) => $store.dispatch('addFriends', props.userId), + select: (props, $store) => get($store.getters.userById(props.userId), 'friends', []), + destory: (props, $store) => $store.dispatch('clearFriends', props.userId), + childPropName: 'entries', + additionalPropNames: ['userId'] + }), + withList({ getEntryProps: user => ({ user }) }) +)(FollowCard) const UserProfile = { + data () { + return { + error: false + } + }, created () { this.$store.commit('clearTimeline', { timeline: 'user' }) this.$store.commit('clearTimeline', { timeline: 'favorites' }) @@ -13,10 +43,20 @@ const UserProfile = { this.startFetchFavorites() if (!this.user.id) { this.$store.dispatch('fetchUser', this.fetchBy) + .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') + } + }) } }, destroyed () { - this.cleanUp(this.userId) + this.cleanUp() }, computed: { timeline () { @@ -101,13 +141,16 @@ const UserProfile = { } this.cleanUp() this.startUp() + }, + $route () { + this.$refs.tabSwitcher.activateTab(0)() } }, components: { UserCardContent, - UserCard, Timeline, - FollowList + FollowerList, + FriendList } } diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue index 79461291..8090efa5 100644 --- a/src/components/user_profile/user_profile.vue +++ b/src/components/user_profile/user_profile.vue @@ -6,10 +6,11 @@ :switcher="true" :selected="timeline.viewing" /> - <tab-switcher :renderOnlyFocused="true"> + <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" @@ -17,16 +18,10 @@ :user-id="fetchBy" /> <div :label="$t('user_card.followees')" v-if="followsTabVisible" :disabled="!user.friends_count"> - <FollowList v-if="user.friends_count > 0" :userId="userId" :showFollowers="false" /> - <div class="userlist-placeholder" v-else> - <i class="icon-spin3 animate-spin"></i> - </div> + <FriendList :userId="userId" /> </div> <div :label="$t('user_card.followers')" v-if="followersTabVisible" :disabled="!user.followers_count"> - <FollowList v-if="user.followers_count > 0" :userId="userId" :showFollowers="true" /> - <div class="userlist-placeholder" v-else> - <i class="icon-spin3 animate-spin"></i> - </div> + <FollowerList :userId="userId" :entryProps="{noFollowsYou: isUs}" /> </div> <Timeline :label="$t('user_card.media')" @@ -54,7 +49,8 @@ </div> </div> <div class="panel-body"> - <i class="icon-spin3 animate-spin"></i> + <span v-if="error">{{ error }}</span> + <i class="icon-spin3 animate-spin" v-else></i> </div> </div> </div> diff --git a/src/components/user_search/user_search.js b/src/components/user_search/user_search.js index 9c026276..55040826 100644 --- a/src/components/user_search/user_search.js +++ b/src/components/user_search/user_search.js @@ -1,8 +1,8 @@ -import UserCard from '../user_card/user_card.vue' +import FollowCard from '../follow_card/follow_card.vue' import userSearchApi from '../../services/new_api/user_search.js' const userSearch = { components: { - UserCard + FollowCard }, props: [ 'query' @@ -10,7 +10,8 @@ const userSearch = { data () { return { username: '', - users: [] + users: [], + loading: false } }, mounted () { @@ -24,14 +25,17 @@ const userSearch = { methods: { newQuery (query) { this.$router.push({ name: 'user-search', query: { query } }) + this.$refs.userSearchInput.focus() }, search (query) { if (!query) { this.users = [] return } + this.loading = true userSearchApi.search({query, store: this.$store}) .then((res) => { + this.loading = false this.users = res }) } diff --git a/src/components/user_search/user_search.vue b/src/components/user_search/user_search.vue index 3c2bd3fb..1269eea6 100644 --- a/src/components/user_search/user_search.vue +++ b/src/components/user_search/user_search.vue @@ -4,13 +4,16 @@ {{$t('nav.user_search')}} </div> <div class="user-search-input-container"> - <input class="user-finder-input" @keyup.enter="newQuery(username)" v-model="username" :placeholder="$t('finder.find_user')"/> + <input class="user-finder-input" ref="userSearchInput" @keyup.enter="newQuery(username)" v-model="username" :placeholder="$t('finder.find_user')"/> <button class="btn search-button" @click="newQuery(username)"> <i class="icon-search"/> </button> </div> - <div class="panel-body"> - <user-card v-for="user in users" :key="user.id" :user="user" :showFollows="true"></user-card> + <div v-if="loading" class="text-center loading-icon"> + <i class="icon-spin3 animate-spin"/> + </div> + <div v-else class="panel-body"> + <FollowCard v-for="user in users" :key="user.id" :user="user"/> </div> </div> </template> @@ -27,4 +30,8 @@ margin-left: 0.5em; } } + +.loading-icon { + padding: 1em; +} </style> diff --git a/src/components/user_settings/user_settings.js b/src/components/user_settings/user_settings.js index fa389c3b..c0ab759c 100644 --- a/src/components/user_settings/user_settings.js +++ b/src/components/user_settings/user_settings.js @@ -1,9 +1,32 @@ -import { unescape } from 'lodash' - +import { compose } from 'vue-compose' +import unescape from 'lodash/unescape' +import get from 'lodash/get' 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 AutoCompleteInput from '../autocomplete_input/autocomplete_input.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 withSubscription from '../../hocs/with_subscription/with_subscription' +import withList from '../../hocs/with_list/with_list' + +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 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 UserSettings = { data () { @@ -21,14 +44,12 @@ const UserSettings = { followImportError: false, followsImported: false, enableFollowsExport: true, - avatarUploading: false, + pickAvatarBtnVisible: true, bannerUploading: false, backgroundUploading: false, followListUploading: false, - avatarPreview: null, bannerPreview: null, backgroundPreview: null, - avatarUploadError: null, bannerUploadError: null, backgroundUploadError: null, deletingAccount: false, @@ -40,10 +61,15 @@ const UserSettings = { activeTab: 'profile' } }, + created () { + this.$store.dispatch('fetchTokens') + }, components: { StyleSwitcher, TabSwitcher, - AutoCompleteInput + ImageCropper, + BlockList, + MuteList }, computed: { user () { @@ -62,6 +88,18 @@ const UserSettings = { private: { selected: this.newDefaultScope === 'private' }, direct: { selected: this.newDefaultScope === 'direct' } } + }, + currentSaveStateNotice () { + return this.$store.state.interface.settings.currentSaveStateNotice + }, + oauthTokens () { + return this.$store.state.oauthTokens.tokens.map(oauthToken => { + return { + id: oauthToken.id, + appName: oauthToken.app_name, + validUntil: new Date(oauthToken.valid_until).toLocaleDateString() + } + }) } }, methods: { @@ -119,35 +157,15 @@ const UserSettings = { } reader.readAsDataURL(file) }, - submitAvatar () { - if (!this.avatarPreview) { return } - - let img = this.avatarPreview - // eslint-disable-next-line no-undef - let imginfo = new Image() - let cropX, cropY, cropW, cropH - imginfo.src = img - if (imginfo.height > imginfo.width) { - cropX = 0 - cropW = imginfo.width - cropY = Math.floor((imginfo.height - imginfo.width) / 2) - cropH = imginfo.width - } else { - cropY = 0 - cropH = imginfo.height - cropX = Math.floor((imginfo.width - imginfo.height) / 2) - cropW = imginfo.height - } - this.avatarUploading = true - this.$store.state.api.backendInteractor.updateAvatar({params: {img, cropX, cropY, cropW, cropH}}).then((user) => { + submitAvatar (cropper, file) { + const img = cropper.getCroppedCanvas().toDataURL(file.type) + return this.$store.state.api.backendInteractor.updateAvatar({ params: { img } }).then((user) => { if (!user.error) { this.$store.commit('addNewUsers', [user]) this.$store.commit('setCurrentUser', user) - this.avatarPreview = null } else { - this.avatarUploadError = this.$t('upload.error.base') + user.error + throw new Error(this.$t('upload.error.base') + user.error) } - this.avatarUploading = false }) }, clearUploadError (slot) { @@ -301,6 +319,11 @@ const UserSettings = { logout () { this.$store.dispatch('logout') this.$router.replace('/') + }, + revokeToken (id) { + if (window.confirm(`${this.$i18n.t('settings.revoke_token')}?`)) { + this.$store.dispatch('revokeToken', id) + } } } } diff --git a/src/components/user_settings/user_settings.vue b/src/components/user_settings/user_settings.vue index ad7c17bd..a1123638 100644 --- a/src/components/user_settings/user_settings.vue +++ b/src/components/user_settings/user_settings.vue @@ -1,7 +1,20 @@ <template> <div class="settings panel panel-default"> <div class="panel-heading"> - {{$t('settings.user_settings')}} + <div class="title"> + {{$t('settings.user_settings')}} + </div> + <transition name="fade"> + <template v-if="currentSaveStateNotice"> + <div @click.prevent class="alert error" v-if="currentSaveStateNotice.error"> + {{ $t('settings.saving_err') }} + </div> + + <div @click.prevent class="alert transparent" v-if="!currentSaveStateNotice.error"> + {{ $t('settings.saving_ok') }} + </div> + </template> + </transition> </div> <div class="panel-body profile-edit"> <tab-switcher> @@ -9,9 +22,9 @@ <div class="setting-item" > <h2>{{$t('settings.name_bio')}}</h2> <p>{{$t('settings.name')}}</p> - <auto-complete-input :classObj="{ 'name-changer': true }" :id="'username'" v-model="newName"/> + <input class='name-changer' id='username' v-model="newName"></input> <p>{{$t('settings.bio')}}</p> - <auto-complete-input :classObj="{ bio: true }" v-model="newBio" :multiline="true"/> + <textarea class="bio" v-model="newBio"></textarea> <p> <input type="checkbox" v-model="newLocked" id="account-locked"> <label for="account-locked">{{$t('settings.lock_account_description')}}</label> @@ -48,19 +61,10 @@ <h2>{{$t('settings.avatar')}}</h2> <p class="visibility-notice">{{$t('settings.avatar_size_instruction')}}</p> <p>{{$t('settings.current_avatar')}}</p> - <img :src="user.profile_image_url_original" class="old-avatar"></img> + <img :src="user.profile_image_url_original" class="current-avatar"></img> <p>{{$t('settings.set_new_avatar')}}</p> - <img class="new-avatar" v-bind:src="avatarPreview" v-if="avatarPreview"> - </img> - <div> - <input type="file" @change="uploadFile('avatar', $event)" ></input> - </div> - <i class="icon-spin4 animate-spin" v-if="avatarUploading"></i> - <button class="btn btn-default" v-else-if="avatarPreview" @click="submitAvatar">{{$t('general.submit')}}</button> - <div class='alert error' v-if="avatarUploadError"> - Error: {{ avatarUploadError }} - <i class="button-icon icon-cancel" @click="clearUploadError('avatar')"></i> - </div> + <button class="btn" type="button" id="pick-avatar" v-show="pickAvatarBtnVisible">{{$t('settings.upload_a_photo')}}</button> + <image-cropper trigger="#pick-avatar" :submitHandler="submitAvatar" @open="pickAvatarBtnVisible=false" @close="pickAvatarBtnVisible=true" /> </div> <div class="setting-item"> <h2>{{$t('settings.profile_banner')}}</h2> @@ -118,6 +122,30 @@ </div> <div class="setting-item"> + <h2>{{$t('settings.oauth_tokens')}}</h2> + <table class="oauth-tokens"> + <thead> + <tr> + <th>{{$t('settings.app_name')}}</th> + <th>{{$t('settings.valid_until')}}</th> + <th></th> + </tr> + </thead> + <tbody> + <tr v-for="oauthToken in oauthTokens" :key="oauthToken.id"> + <td>{{oauthToken.appName}}</td> + <td>{{oauthToken.validUntil}}</td> + <td class="actions"> + <button class="btn btn-default" @click="revokeToken(oauthToken.id)"> + {{$t('settings.revoke_token')}} + </button> + </td> + </tr> + </tbody> + </table> + </div> + + <div class="setting-item"> <h2>{{$t('settings.delete_account')}}</h2> <p v-if="!deletingAccount">{{$t('settings.delete_account_description')}}</p> <div v-if="deletingAccount"> @@ -158,6 +186,12 @@ <h2>{{$t('settings.follow_export_processing')}}</h2> </div> </div> + + <div :label="$t('settings.blocks_tab')"> + <block-list :refresh="true"> + <template slot="empty">{{$t('settings.no_blocks')}}</template> + </block-list> + </div> </tab-switcher> </div> </div> @@ -167,6 +201,8 @@ </script> <style lang="scss"> +@import '../../_variables.scss'; + .profile-edit { .bio { margin: 0; @@ -193,5 +229,25 @@ .bg { max-width: 100%; } + + .current-avatar { + display: block; + width: 150px; + height: 150px; + border-radius: $fallback--avatarRadius; + border-radius: var(--avatarRadius, $fallback--avatarRadius); + } + + .oauth-tokens { + width: 100%; + + th { + text-align: left; + } + + .actions { + text-align: right; + } + } } </style> diff --git a/src/components/who_to_follow/who_to_follow.js b/src/components/who_to_follow/who_to_follow.js index 82098fc2..be0b8827 100644 --- a/src/components/who_to_follow/who_to_follow.js +++ b/src/components/who_to_follow/who_to_follow.js @@ -1,9 +1,9 @@ import apiService from '../../services/api/api.service.js' -import UserCard from '../user_card/user_card.vue' +import FollowCard from '../follow_card/follow_card.vue' const WhoToFollow = { components: { - UserCard + FollowCard }, data () { return { diff --git a/src/components/who_to_follow/who_to_follow.vue b/src/components/who_to_follow/who_to_follow.vue index df2e03c8..1630f5ac 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"> - <user-card v-for="user in users" :key="user.id" :user="user" :showFollows="true"></user-card> + <FollowCard v-for="user in users" :key="user.id" :user="user"/> </div> </div> </template> diff --git a/src/hocs/with_list/with_list.js b/src/hocs/with_list/with_list.js new file mode 100644 index 00000000..896f8fc8 --- /dev/null +++ b/src/hocs/with_list/with_list.js @@ -0,0 +1,40 @@ +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 new file mode 100644 index 00000000..c6e13d5b --- /dev/null +++ b/src/hocs/with_list/with_list.scss @@ -0,0 +1,6 @@ +.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.js b/src/hocs/with_load_more/with_load_more.js new file mode 100644 index 00000000..74979b87 --- /dev/null +++ b/src/hocs/with_load_more/with_load_more.js @@ -0,0 +1,94 @@ +import Vue from 'vue' +import isEmpty from 'lodash/isEmpty' +import { getComponentProps } from '../../services/component_utils/component_utils' +import './with_load_more.scss' + +const withLoadMore = ({ + fetch, // function to fetch entries and return a promise + select, // function to select data from store + destroy, // function called at "destroyed" lifecycle + childPropName = 'entries', // name of the prop to be passed into the wrapped component + additionalPropNames = [] // additional prop name list of the wrapper component +}) => (WrappedComponent) => { + const originalProps = Object.keys(getComponentProps(WrappedComponent)) + const props = originalProps.filter(v => v !== childPropName).concat(additionalPropNames) + + return Vue.component('withLoadMore', { + render (createElement) { + const props = { + props: { + ...this.$props, + [childPropName]: this.entries + }, + on: this.$listeners, + scopedSlots: this.$scopedSlots + } + const children = Object.entries(this.$slots).map(([key, value]) => createElement('template', { slot: key }, value)) + return ( + <div class="with-load-more"> + <WrappedComponent {...props}> + {children} + </WrappedComponent> + <div class="with-load-more-footer"> + {this.error && <a onClick={this.fetchEntries} class="alert error">{this.$t('general.generic_error')}</a>} + {!this.error && this.loading && <i class="icon-spin3 animate-spin"/>} + {!this.error && !this.loading && !this.bottomedOut && <a onClick={this.fetchEntries}>{this.$t('general.more')}</a>} + </div> + </div> + ) + }, + props, + data () { + return { + loading: false, + bottomedOut: false, + error: false + } + }, + computed: { + entries () { + return select(this.$props, this.$store) || [] + } + }, + created () { + window.addEventListener('scroll', this.scrollLoad) + if (this.entries.length === 0) { + this.fetchEntries() + } + }, + destroyed () { + window.removeEventListener('scroll', this.scrollLoad) + destroy && destroy(this.$props, this.$store) + }, + methods: { + fetchEntries () { + if (!this.loading) { + this.loading = true + this.error = false + fetch(this.$props, this.$store) + .then((newEntries) => { + this.loading = false + this.bottomedOut = isEmpty(newEntries) + }) + .catch(() => { + this.loading = false + this.error = true + }) + } + }, + scrollLoad (e) { + const bodyBRect = document.body.getBoundingClientRect() + const height = Math.max(bodyBRect.height, -(bodyBRect.y)) + if (this.loading === false && + this.bottomedOut === false && + this.$el.offsetHeight > 0 && + (window.innerHeight + window.pageYOffset) >= (height - 750) + ) { + this.fetchEntries() + } + } + } + }) +} + +export default withLoadMore diff --git a/src/hocs/with_load_more/with_load_more.scss b/src/hocs/with_load_more/with_load_more.scss new file mode 100644 index 00000000..1a0a9c40 --- /dev/null +++ b/src/hocs/with_load_more/with_load_more.scss @@ -0,0 +1,10 @@ +.with-load-more { + &-footer { + padding: 10px; + text-align: center; + + .error { + font-size: 14px; + } + } +}
\ No newline at end of file diff --git a/src/hocs/with_subscription/with_subscription.js b/src/hocs/with_subscription/with_subscription.js new file mode 100644 index 00000000..679409cf --- /dev/null +++ b/src/hocs/with_subscription/with_subscription.js @@ -0,0 +1,84 @@ +import Vue from 'vue' +import isEmpty from 'lodash/isEmpty' +import { getComponentProps } from '../../services/component_utils/component_utils' +import './with_subscription.scss' + +const withSubscription = ({ + fetch, // function to fetch entries and return a promise + select, // function to select data from store + childPropName = 'content', // name of the prop to be passed into the wrapped component + additionalPropNames = [] // additional prop name list of the wrapper component +}) => (WrappedComponent) => { + const originalProps = Object.keys(getComponentProps(WrappedComponent)) + const props = originalProps.filter(v => v !== childPropName).concat(additionalPropNames) + + return Vue.component('withSubscription', { + props: [ + ...props, + 'refresh' // boolean saying to force-fetch data whenever created + ], + render (createElement) { + if (!this.error && !this.loading) { + const props = { + props: { + ...this.$props, + [childPropName]: this.fetchedData + }, + on: this.$listeners, + scopedSlots: this.$scopedSlots + } + const children = Object.entries(this.$slots).map(([key, value]) => createElement('template', { slot: key }, value)) + return ( + <div class="with-subscription"> + <WrappedComponent {...props}> + {children} + </WrappedComponent> + </div> + ) + } else { + return ( + <div class="with-subscription-loading"> + {this.error + ? <a onClick={this.fetchData} class="alert error">{this.$t('general.generic_error')}</a> + : <i class="icon-spin3 animate-spin"/> + } + </div> + ) + } + }, + data () { + return { + loading: false, + error: false + } + }, + computed: { + fetchedData () { + return select(this.$props, this.$store) + } + }, + created () { + if (this.refresh || isEmpty(this.fetchedData)) { + this.fetchData() + } + }, + methods: { + fetchData () { + if (!this.loading) { + this.loading = true + this.error = false + fetch(this.$props, this.$store) + .then(() => { + this.loading = false + }) + .catch(() => { + this.error = true + this.loading = false + }) + } + } + } + }) +} + +export default withSubscription diff --git a/src/hocs/with_subscription/with_subscription.scss b/src/hocs/with_subscription/with_subscription.scss new file mode 100644 index 00000000..52c7d94c --- /dev/null +++ b/src/hocs/with_subscription/with_subscription.scss @@ -0,0 +1,10 @@ +.with-subscription { + &-loading { + padding: 10px; + text-align: center; + + .error { + font-size: 14px; + } + } +}
\ No newline at end of file diff --git a/src/i18n/ar.json b/src/i18n/ar.json index ac7d0f1a..242dab78 100644 --- a/src/i18n/ar.json +++ b/src/i18n/ar.json @@ -134,6 +134,11 @@ "notification_visibility_mentions": "الإشارات", "notification_visibility_repeats": "", "nsfw_clickthrough": "", + "oauth_tokens": "رموز OAuth", + "token": "رمز", + "refresh_token": "رمز التحديث", + "valid_until": "صالح حتى", + "revoke_token": "سحب", "panelRadius": "", "pause_on_unfocused": "", "presets": "النماذج", diff --git a/src/i18n/ca.json b/src/i18n/ca.json index fa517e22..d2f285df 100644 --- a/src/i18n/ca.json +++ b/src/i18n/ca.json @@ -132,6 +132,11 @@ "notification_visibility_repeats": "Republica una entrada meva", "no_rich_text_description": "Neteja el formatat de text de totes les entrades", "nsfw_clickthrough": "Amaga el contingut NSFW darrer d'una imatge clicable", + "oauth_tokens": "Llistats OAuth", + "token": "Token", + "refresh_token": "Actualitza el token", + "valid_until": "Vàlid fins", + "revoke_token": "Revocar", "panelRadius": "Panells", "pause_on_unfocused": "Pausa la reproducció en continu quan la pestanya perdi el focus", "presets": "Temes", diff --git a/src/i18n/cs.json b/src/i18n/cs.json new file mode 100644 index 00000000..6326032c --- /dev/null +++ b/src/i18n/cs.json @@ -0,0 +1,427 @@ +{ + "chat": { + "title": "Chat" + }, + "features_panel": { + "chat": "Chat", + "gopher": "Gopher", + "media_proxy": "Mediální proxy", + "scope_options": "Možnosti rozsahů", + "text_limit": "Textový limit", + "title": "Vlastnosti", + "who_to_follow": "Koho sledovat" + }, + "finder": { + "error_fetching_user": "Chyba při načítání uživatele", + "find_user": "Najít uživatele" + }, + "general": { + "apply": "Použít", + "submit": "Odeslat", + "more": "Více", + "generic_error": "Vyskytla se chyba", + "optional": "volitelné" + }, + "image_cropper": { + "crop_picture": "Oříznout obrázek", + "save": "Uložit", + "cancel": "Zrušit" + }, + "login": { + "login": "Přihlásit", + "description": "Přihlásit pomocí OAuth", + "logout": "Odhlásit", + "password": "Heslo", + "placeholder": "např. lain", + "register": "Registrovat", + "username": "Uživatelské jméno", + "hint": "Chcete-li se přidat do diskuze, přihlaste se" + }, + "media_modal": { + "previous": "Předchozí", + "next": "Další" + }, + "nav": { + "about": "O instanci", + "back": "Zpět", + "chat": "Místní chat", + "friend_requests": "Požadavky o sledování", + "mentions": "Zmínky", + "dms": "Přímé zprávy", + "public_tl": "Veřejná časová osa", + "timeline": "Časová osa", + "twkn": "Celá známá síť", + "user_search": "Hledání uživatelů", + "who_to_follow": "Koho sledovat", + "preferences": "Předvolby" + }, + "notifications": { + "broken_favorite": "Neznámý příspěvek, hledám jej…", + "favorited_you": "si oblíbil/a váš příspěvek", + "followed_you": "vás nyní sleduje", + "load_older": "Načíst starší oznámení", + "notifications": "Oznámení", + "read": "Číst!", + "repeated_you": "zopakoval/a váš příspěvek", + "no_more_notifications": "Žádná další oznámení" + }, + "post_status": { + "new_status": "Napsat nový příspěvek", + "account_not_locked_warning": "Váš účet není {0}. Kdokoliv vás může sledovat a vidět vaše příspěvky pouze pro sledující.", + "account_not_locked_warning_link": "uzamčen", + "attachments_sensitive": "Označovat přílohy jako citlivé", + "content_type": { + "plain_text": "Prostý text" + }, + "content_warning": "Předmět (volitelný)", + "default": "Právě jsem přistál v L.A.", + "direct_warning": "Tento příspěvek uvidí pouze všichni zmínění uživatelé.", + "posting": "Přispívání", + "scope": { + "direct": "Přímý - Poslat pouze zmíněným uživatelům", + "private": "Pouze pro sledující - Poslat pouze sledujícím", + "public": "Veřejný - Poslat na veřejné časové osy", + "unlisted": "Neuvedený - Neposlat na veřejné časové osy" + } + }, + "registration": { + "bio": "O vás", + "email": "E-mail", + "fullname": "Zobrazované jméno", + "password_confirm": "Potvrzení hesla", + "registration": "Registrace", + "token": "Token pozvánky", + "captcha": "CAPTCHA", + "new_captcha": "Kliknutím na obrázek získáte novou CAPTCHA", + "username_placeholder": "např. lain", + "fullname_placeholder": "např. Lain Iwakura", + "bio_placeholder": "např.\nNazdar, jsem Lain\nJsem anime dívka a žiji v příměstském Japonsku. Možná mě znáte z Wired.", + "validations": { + "username_required": "nemůže být prázdné", + "fullname_required": "nemůže být prázdné", + "email_required": "nemůže být prázdný", + "password_required": "nemůže být prázdné", + "password_confirmation_required": "nemůže být prázdné", + "password_confirmation_match": "musí být stejné jako heslo" + } + }, + "settings": { + "app_name": "Název aplikace", + "attachmentRadius": "Přílohy", + "attachments": "Přílohy", + "autoload": "Povolit automatické načítání při rolování dolů", + "avatar": "Avatar", + "avatarAltRadius": "Avatary (oznámení)", + "avatarRadius": "Avatary", + "background": "Pozadí", + "bio": "O vás", + "blocks_tab": "Blokování", + "btnRadius": "Tlačítka", + "cBlue": "Modrá (Odpovědět, sledovat)", + "cGreen": "Zelená (Zopakovat)", + "cOrange": "Oranžová (Oblíbit)", + "cRed": "Červená (Zrušit)", + "change_password": "Změnit heslo", + "change_password_error": "Při změně vašeho hesla se vyskytla chyba.", + "changed_password": "Heslo bylo úspěšně změněno!", + "collapse_subject": "Zabalit příspěvky s předměty", + "composing": "Komponování", + "confirm_new_password": "Potvrďte nové heslo", + "current_avatar": "Váš současný avatar", + "current_password": "Současné heslo", + "current_profile_banner": "Váš současný profilový banner", + "data_import_export_tab": "Import/export dat", + "default_vis": "Výchozí rozsah viditelnosti", + "delete_account": "Smazat účet", + "delete_account_description": "Trvale smaže váš účet a všechny vaše příspěvky.", + "delete_account_error": "Při mazání vašeho účtu nastala chyba. Pokud tato chyba bude trvat, kontaktujte prosím admministrátora vaší instance.", + "delete_account_instructions": "Pro potvrzení smazání účtu napište své heslo do pole níže.", + "avatar_size_instruction": "Doporučená minimální velikost pro avatarové obrázky je 150x150 pixelů.", + "export_theme": "Uložit přednastavení", + "filtering": "Filtrování", + "filtering_explanation": "Všechny příspěvky obsahující tato slova budou skryty. Napište jedno slovo na každý řádek", + "follow_export": "Export sledovaných", + "follow_export_button": "Exportovat vaše sledované do souboru CSV", + "follow_export_processing": "Zpracovávám, brzy si budete moci stáhnout váš soubor", + "follow_import": "Import sledovaných", + "follow_import_error": "Chyba při importování sledovaných", + "follows_imported": "Sledovaní importováni! Jejich zpracování bude chvilku trvat.", + "foreground": "Popředí", + "general": "Obecné", + "hide_attachments_in_convo": "Skrývat přílohy v konverzacích", + "hide_attachments_in_tl": "Skrývat přílohy v časové ose", + "max_thumbnails": "Maximální počet miniatur na příspěvek", + "hide_isp": "Skrýt panel specifický pro instanci", + "preload_images": "Přednačítat obrázky", + "use_one_click_nsfw": "Otevírat citlivé přílohy pouze jedním kliknutím", + "hide_post_stats": "Skrývat statistiky příspěvků (např. počet oblíbení)", + "hide_user_stats": "Skrývat statistiky uživatelů (např. počet sledujících)", + "hide_filtered_statuses": "Skrývat filtrované příspěvky", + "import_followers_from_a_csv_file": "Importovat sledované ze souboru CSV", + "import_theme": "Načíst přednastavení", + "inputRadius": "Vstupní pole", + "checkboxRadius": "Zaškrtávací pole", + "instance_default": "(výchozí: {value})", + "instance_default_simple": "(výchozí)", + "interface": "Rozhraní", + "interfaceLanguage": "Jazyk rozhraní", + "invalid_theme_imported": "Zvolený soubor není podporovaný motiv Pleroma. Nebyly provedeny žádné změny s vaším motivem.", + "limited_availability": "Nedostupné ve vašem prohlížeči", + "links": "Odkazy", + "lock_account_description": "Omezit váš účet pouze na schválené sledující", + "loop_video": "Opakovat videa", + "loop_video_silent_only": "Opakovat pouze videa beze zvuku (t.j. „GIFy“ na Mastodonu)", + "mutes_tab": "Ignorování", + "play_videos_in_modal": "Přehrávat videa přímo v prohlížeči médií", + "use_contain_fit": "Neořezávat přílohu v miniaturách", + "name": "Jméno", + "name_bio": "Jméno a popis", + "new_password": "Nové heslo", + "notification_visibility": "Typy oznámení k zobrazení", + "notification_visibility_follows": "Sledující", + "notification_visibility_likes": "Oblíbení", + "notification_visibility_mentions": "Zmínky", + "notification_visibility_repeats": "Zopakování", + "no_rich_text_description": "Odstranit ze všech příspěvků formátování textu", + "no_blocks": "Žádná blokování", + "no_mutes": "Žádná ignorování", + "hide_follows_description": "Nezobrazovat, koho sleduji", + "hide_followers_description": "Nezobrazovat, kdo mě sleduje", + "show_admin_badge": "Zobrazovat v mém profilu odznak administrátora", + "show_moderator_badge": "Zobrazovat v mém profilu odznak moderátora", + "nsfw_clickthrough": "Povolit prokliknutelné skrývání citlivých příloh", + "oauth_tokens": "Tokeny OAuth", + "token": "Token", + "refresh_token": "Obnovit token", + "valid_until": "Platný do", + "revoke_token": "Odvolat", + "panelRadius": "Panely", + "pause_on_unfocused": "Pozastavit streamování, pokud není záložka prohlížeče v soustředění", + "presets": "Přednastavení", + "profile_background": "Profilové pozadí", + "profile_banner": "Profilový banner", + "profile_tab": "Profil", + "radii_help": "Nastavit zakulacení rohů rozhraní (v pixelech)", + "replies_in_timeline": "Odpovědi v časové ose", + "reply_link_preview": "Povolit náhledy odkazu pro odpověď při přejetí myši", + "reply_visibility_all": "Zobrazit všechny odpovědiShow all replies", + "reply_visibility_following": "Zobrazit pouze odpovědi směřované na mě nebo uživatele, které sleduji", + "reply_visibility_self": "Zobrazit pouze odpovědi směřované na mě", + "saving_err": "Chyba při ukládání nastavení", + "saving_ok": "Nastavení uložena", + "security_tab": "Bezpečnost", + "scope_copy": "Kopírovat rozsah při odpovídání (přímé zprávy jsou vždy kopírovány)", + "set_new_avatar": "Nastavit nový avatar", + "set_new_profile_background": "Nastavit nové profilové pozadí", + "set_new_profile_banner": "Nastavit nový profilový banner", + "settings": "Nastavení", + "subject_input_always_show": "Vždy zobrazit pole pro předmět", + "subject_line_behavior": "Kopírovat předmět při odpovídání", + "subject_line_email": "Jako u e-mailu: „re: předmět“", + "subject_line_mastodon": "Jako u Mastodonu: zkopírovat tak, jak je", + "subject_line_noop": "Nekopírovat", + "post_status_content_type": "Publikovat typ obsahu příspěvku", + "status_content_type_plain": "Prostý text", + "stop_gifs": "Přehrávat GIFy při přejetí myši", + "streaming": "Povolit automatické streamování nových příspěvků při rolování nahoru", + "text": "Text", + "theme": "Motiv", + "theme_help": "Použijte hexadecimální barevné kódy (#rrggbb) pro přizpůsobení vašeho barevného motivu.", + "theme_help_v2_1": "Zaškrtnutím pole můžete také přepsat barvy a průhlednost některých komponentů, pro smazání všech přednastavení použijte tlačítko „Smazat vše“.", + "theme_help_v2_2": "Ikony pod některými položkami jsou indikátory kontrastu pozadí/textu, pro detailní informace nad nimi přejeďte myší. Prosím berte na vědomí, že při používání kontrastu průhlednosti ukazují indikátory nejhorší možný případ.", + "tooltipRadius": "Popisky/upozornění", + "upload_a_photo": "Nahrát fotku", + "user_settings": "Uživatelská nastavení", + "values": { + "false": "ne", + "true": "ano" + }, + "notifications": "Oznámení", + "enable_web_push_notifications": "Povolit webová push oznámení", + "style": { + "switcher": { + "keep_color": "Ponechat barvy", + "keep_shadows": "Ponechat stíny", + "keep_opacity": "Ponechat průhlednost", + "keep_roundness": "Ponechat kulatost", + "keep_fonts": "Keep fonts", + "save_load_hint": "Možnosti „Ponechat“ dočasně ponechávají aktuálně nastavené možností při volení či nahrávání motivů, také tyto možnosti ukládají při exportování motivu. Pokud není žádné pole zaškrtnuto, uloží export motivu všechno.", + "reset": "Resetovat", + "clear_all": "Vymazat vše", + "clear_opacity": "Vymazat průhlednost" + }, + "common": { + "color": "Barva", + "opacity": "Průhlednost", + "contrast": { + "hint": "Poměr kontrastu je {ratio}, {level} {context}", + "level": { + "aa": "splňuje směrnici úrovně AA (minimální)", + "aaa": "splňuje směrnici úrovně AAA (doporučováno)", + "bad": "nesplňuje žádné směrnice přístupnosti" + }, + "context": { + "18pt": "pro velký (18+ bodů) text", + "text": "pro text" + } + } + }, + "common_colors": { + "_tab_label": "Obvyklé", + "main": "Obvyklé barvy", + "foreground_hint": "Pro detailnější kontrolu viz záložka „Pokročilé“", + "rgbo": "Ikony, odstíny, odznaky" + }, + "advanced_colors": { + "_tab_label": "Pokročilé", + "alert": "Pozadí upozornění", + "alert_error": "Chyba", + "badge": "Pozadí odznaků", + "badge_notification": "Oznámení", + "panel_header": "Záhlaví panelu", + "top_bar": "Vrchní pruh", + "borders": "Okraje", + "buttons": "Tlačítka", + "inputs": "Vstupní pole", + "faint_text": "Vybledlý text" + }, + "radii": { + "_tab_label": "Kulatost" + }, + "shadows": { + "_tab_label": "Stín a osvětlení", + "component": "Komponent", + "override": "Přepsat", + "shadow_id": "Stín #{value}", + "blur": "Rozmazání", + "spread": "Rozsah", + "inset": "Vsazení", + "hint": "Pro stíny můžete také použít --variable jako hodnotu barvy pro použití proměnných CSS3. Prosím berte na vědomí, že nastavení průhlednosti v tomto případě nebude fungovat.", + "filter_hint": { + "always_drop_shadow": "Varování, tento stín vždy používá {0}, když to prohlížeč podporuje.", + "drop_shadow_syntax": "{0} nepodporuje parametr {1} a klíčové slovo {2}.", + "avatar_inset": "Prosím berte na vědomí, že kombinování vsazených i nevsazených stínů u avatarů může u průhledných avatarů dát neočekávané výsledky.", + "spread_zero": "Stíny s rozsahem > 0 se zobrazí, jako kdyby byl rozsah nastaven na nulu", + "inset_classic": "Vsazené stíny budou používat {0}" + }, + "components": { + "panel": "Panel", + "panelHeader": "Záhlaví panelu", + "topBar": "Vrchní pruh", + "avatar": "Avatar uživatele (v zobrazení profilu)", + "avatarStatus": "Avatar uživatele (v zobrazení příspěvku)", + "popup": "Vyskakovací okna a popisky", + "button": "Tlačítko", + "buttonHover": "Tlačítko (přejetí myši)", + "buttonPressed": "Tlačítko (stisknuto)", + "buttonPressedHover": "Button (stisknuto+přejetí myši)", + "input": "Vstupní pole" + } + }, + "fonts": { + "_tab_label": "Písma", + "help": "Zvolte písmo, které bude použito pro prvky rozhraní. U možnosti „vlastní“ musíte zadat přesný název písma tak, jak se zobrazuje v systému.", + "components": { + "interface": "Rozhraní", + "input": "Vstupní pole", + "post": "Text příspěvků", + "postCode": "Neproporcionální text v příspěvku (formátovaný text)" + }, + "family": "Název písma", + "size": "Velikost (v pixelech)", + "weight": "Tloušťka", + "custom": "Vlastní" + }, + "preview": { + "header": "Náhled", + "content": "Obsah", + "error": "Příklad chyby", + "button": "Tlačítko", + "text": "Spousta dalšího {0} a {1}", + "mono": "obsahu", + "input": "Just landed in L.A.", + "faint_link": "pomocný manuál", + "fine_print": "Přečtěte si náš {0} a nenaučte se nic užitečného!", + "header_faint": "Tohle je v pohodě", + "checkbox": "Pročetl/a jsem podmínky používání", + "link": "hezký malý odkaz" + } + } + }, + "timeline": { + "collapse": "Zabalit", + "conversation": "Konverzace", + "error_fetching": "Chyba při načítání aktualizací", + "load_older": "Načíst starší příspěvky", + "no_retweet_hint": "Příspěvek je označen jako pouze pro sledující či přímý a nemůže být zopakován", + "repeated": "zopakoval/a", + "show_new": "Zobrazit nové", + "up_to_date": "Aktuální", + "no_more_statuses": "Žádné další příspěvky", + "no_statuses": "Žádné příspěvky" + }, + "status": { + "reply_to": "Odpovědět uživateli", + "replies_list": "Odpovědi:" + }, + + "user_card": { + "approve": "Schválit", + "block": "Blokovat", + "blocked": "Blokován/a!", + "deny": "Zamítnout", + "favorites": "Oblíbené", + "follow": "Sledovat", + "follow_sent": "Požadavek odeslán!", + "follow_progress": "Odeslílám požadavek…", + "follow_again": "Odeslat požadavek znovu?", + "follow_unfollow": "Přestat sledovat", + "followees": "Sledovaní", + "followers": "Sledující", + "following": "Sledujete!", + "follows_you": "Sleduje vás!", + "its_you": "Jste to vy!", + "media": "Média", + "mute": "Ignorovat", + "muted": "Ignorován/a", + "per_day": "za den", + "remote_follow": "Vzdálené sledování", + "statuses": "Příspěvky", + "unblock": "Odblokovat", + "unblock_progress": "Odblokuji…", + "block_progress": "Blokuji…", + "unmute": "Přestat ignorovat", + "unmute_progress": "Ruším ignorování…", + "mute_progress": "Ignoruji…" + }, + "user_profile": { + "timeline_title": "Uživatelská časová osa", + "profile_does_not_exist": "Omlouváme se, tento profil neexistuje.", + "profile_loading_error": "Omlouváme se, při načítání tohoto profilu se vyskytla chyba." + }, + "who_to_follow": { + "more": "Více", + "who_to_follow": "Koho sledovat" + }, + "tool_tip": { + "media_upload": "Nahrát média", + "repeat": "Zopakovat", + "reply": "Odpovědět", + "favorite": "Oblíbit", + "user_settings": "Uživatelské nastavení" + }, + "upload":{ + "error": { + "base": "Nahrávání selhalo.", + "file_too_big": "Soubor je úříliš velký [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]", + "default": "Zkuste to znovu později" + }, + "file_size_units": { + "B": "B", + "KiB": "KiB", + "MiB": "MiB", + "GiB": "GiB", + "TiB": "TiB" + } + } +} diff --git a/src/i18n/de.json b/src/i18n/de.json index d0bfba38..07d44348 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -159,6 +159,11 @@ "hide_follows_description": "Zeige nicht, wem ich folge", "hide_followers_description": "Zeige nicht, wer mir folgt", "nsfw_clickthrough": "Aktiviere ausblendbares Overlay für Anhänge, die als NSFW markiert sind", + "oauth_tokens": "OAuth-Token", + "token": "Zeichen", + "refresh_token": "Token aktualisieren", + "valid_until": "Gültig bis", + "revoke_token": "Widerrufen", "panelRadius": "Panel", "pause_on_unfocused": "Streaming pausieren, wenn das Tab nicht fokussiert ist", "presets": "Voreinstellungen", diff --git a/src/i18n/en.json b/src/i18n/en.json index c664fbfa..01fe2fba 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -19,7 +19,13 @@ "apply": "Apply", "submit": "Submit", "more": "More", - "generic_error": "An error occured" + "generic_error": "An error occured", + "optional": "optional" + }, + "image_cropper": { + "crop_picture": "Crop picture", + "save": "Save", + "cancel": "Cancel" }, "login": { "login": "Log in", @@ -31,6 +37,10 @@ "username": "Username", "hint": "Log in to join the discussion" }, + "media_modal": { + "previous": "Previous", + "next": "Next" + }, "nav": { "about": "About", "back": "Back", @@ -61,7 +71,9 @@ "account_not_locked_warning_link": "locked", "attachments_sensitive": "Mark attachments as sensitive", "content_type": { - "plain_text": "Plain text" + "text/plain": "Plain text", + "text/html": "HTML", + "text/markdown": "Markdown" }, "content_warning": "Subject (optional)", "default": "Just landed in L.A.", @@ -83,6 +95,9 @@ "token": "Invite token", "captcha": "CAPTCHA", "new_captcha": "Click the image to get a new captcha", + "username_placeholder": "e.g. lain", + "fullname_placeholder": "e.g. Lain Iwakura", + "bio_placeholder": "e.g.\nHi, I'm Lain\nI’m an anime girl living in suburban Japan. You may know me from the Wired.", "validations": { "username_required": "cannot be left blank", "fullname_required": "cannot be left blank", @@ -93,6 +108,7 @@ } }, "settings": { + "app_name": "App name", "attachmentRadius": "Attachments", "attachments": "Attachments", "autoload": "Enable automatic loading when scrolled to the bottom", @@ -101,6 +117,7 @@ "avatarRadius": "Avatars", "background": "Background", "bio": "Bio", + "blocks_tab": "Blocks", "btnRadius": "Buttons", "cBlue": "Blue (Reply, follow)", "cGreen": "Green (Retweet)", @@ -135,6 +152,7 @@ "general": "General", "hide_attachments_in_convo": "Hide attachments in conversations", "hide_attachments_in_tl": "Hide attachments in timeline", + "max_thumbnails": "Maximum amount of thumbnails per post", "hide_isp": "Hide instance-specific panel", "preload_images": "Preload images", "use_one_click_nsfw": "Open NSFW attachments with just one click", @@ -155,6 +173,7 @@ "lock_account_description": "Restrict your account to approved followers only", "loop_video": "Loop videos", "loop_video_silent_only": "Loop only videos without sound (i.e. Mastodon's \"gifs\")", + "mutes_tab": "Mutes", "play_videos_in_modal": "Play videos directly in the media viewer", "use_contain_fit": "Don't crop the attachment in thumbnails", "name": "Name", @@ -166,11 +185,18 @@ "notification_visibility_mentions": "Mentions", "notification_visibility_repeats": "Repeats", "no_rich_text_description": "Strip rich text formatting from all posts", + "no_blocks": "No blocks", + "no_mutes": "No mutes", "hide_follows_description": "Don't show who I'm following", "hide_followers_description": "Don't show who's following me", "show_admin_badge": "Show Admin badge in my profile", "show_moderator_badge": "Show Moderator badge in my profile", "nsfw_clickthrough": "Enable clickthrough NSFW attachment hiding", + "oauth_tokens": "OAuth tokens", + "token": "Token", + "refresh_token": "Refresh Token", + "valid_until": "Valid Until", + "revoke_token": "Revoke", "panelRadius": "Panels", "pause_on_unfocused": "Pause streaming when tab is not focused", "presets": "Presets", @@ -197,7 +223,6 @@ "subject_line_mastodon": "Like mastodon: copy as is", "subject_line_noop": "Do not copy", "post_status_content_type": "Post status content type", - "status_content_type_plain": "Plain text", "stop_gifs": "Play-on-hover GIFs", "streaming": "Enable automatic streaming of new posts when scrolled to the top", "text": "Text", @@ -206,6 +231,7 @@ "theme_help_v2_1": "You can also override certain component's colors and opacity by toggling the checkbox, use \"Clear all\" button to clear all overrides.", "theme_help_v2_2": "Icons underneath some entries are background/text contrast indicators, hover over for detailed info. Please keep in mind that when using transparency contrast indicators show the worst possible case.", "tooltipRadius": "Tooltips/alerts", + "upload_a_photo": "Upload a photo", "user_settings": "User Settings", "values": { "false": "no", @@ -332,7 +358,12 @@ "repeated": "repeated", "show_new": "Show new", "up_to_date": "Up-to-date", - "no_more_statuses": "No more statuses" + "no_more_statuses": "No more statuses", + "no_statuses": "No statuses" + }, + "status": { + "reply_to": "Reply to", + "replies_list": "Replies:" }, "user_card": { "approve": "Approve", @@ -344,7 +375,7 @@ "follow_sent": "Request sent!", "follow_progress": "Requesting…", "follow_again": "Send request again?", - "follow_unfollow": "Stop following", + "follow_unfollow": "Unfollow", "followees": "Following", "followers": "Followers", "following": "Following!", @@ -355,10 +386,18 @@ "muted": "Muted", "per_day": "per day", "remote_follow": "Remote follow", - "statuses": "Statuses" + "statuses": "Statuses", + "unblock": "Unblock", + "unblock_progress": "Unblocking...", + "block_progress": "Blocking...", + "unmute": "Unmute", + "unmute_progress": "Unmuting...", + "mute_progress": "Muting..." }, "user_profile": { - "timeline_title": "User Timeline" + "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." }, "who_to_follow": { "more": "More", diff --git a/src/i18n/eo.json b/src/i18n/eo.json index ed4b50e3..34851a44 100644 --- a/src/i18n/eo.json +++ b/src/i18n/eo.json @@ -2,118 +2,420 @@ "chat": { "title": "Babilejo" }, + "features_panel": { + "chat": "Babilejo", + "gopher": "Gopher", + "media_proxy": "Aŭdvidaĵa prokurilo", + "scope_options": "Agordoj de amplekso", + "text_limit": "Teksta limo", + "title": "Funkcioj", + "who_to_follow": "Kiun aboni" + }, "finder": { "error_fetching_user": "Eraro alportante uzanton", "find_user": "Trovi uzanton" }, "general": { "apply": "Apliki", - "submit": "Sendi" + "submit": "Sendi", + "more": "Pli", + "generic_error": "Eraro okazis", + "optional": "Malnepra" + }, + "image_cropper": { + "crop_picture": "Tondi bildon", + "save": "Konservi", + "cancel": "Nuligi" }, "login": { - "login": "Ensaluti", - "logout": "Elsaluti", + "login": "Saluti", + "description": "Saluti per OAuth", + "logout": "Adiaŭi", "password": "Pasvorto", "placeholder": "ekz. lain", "register": "Registriĝi", - "username": "Salutnomo" + "username": "Salutnomo", + "hint": "Salutu por partopreni la diskutadon" + }, + "media_modal": { + "previous": "Antaŭa", + "next": "Sekva" }, "nav": { + "about": "Pri", + "back": "Reen", "chat": "Loka babilejo", + "friend_requests": "Abonaj petoj", "mentions": "Mencioj", + "dms": "Rektaj mesaĝoj", "public_tl": "Publika tempolinio", "timeline": "Tempolinio", - "twkn": "La tuta konata reto" + "twkn": "La tuta konata reto", + "user_search": "Serĉi uzantojn", + "who_to_follow": "Kiun aboni", + "preferences": "Agordoj" }, "notifications": { + "broken_favorite": "Nekonata stato, serĉante ĝin…", "favorited_you": "ŝatis vian staton", "followed_you": "ekabonis vin", + "load_older": "Enlegi pli malnovajn sciigojn", "notifications": "Sciigoj", "read": "Legite!", - "repeated_you": "ripetis vian staton" + "repeated_you": "ripetis vian staton", + "no_more_notifications": "Neniuj pliaj sciigoj" }, "post_status": { + "new_status": "Afiŝi novan staton", + "account_not_locked_warning": "Via konto ne estas {0}. Iu ajn povas vin aboni por vidi viajn afiŝoj nur por abonantoj.", + "account_not_locked_warning_link": "ŝlosita", + "attachments_sensitive": "Marki kunsendaĵojn kiel konsternajn", + "content_type": { + "plain_text": "Plata teksto" + }, + "content_warning": "Temo (malnepra)", "default": "Ĵus alvenis al la Universala Kongreso!", - "posting": "Afiŝante" + "direct_warning": "Ĉi tiu afiŝo estos videbla nur por ĉiuj menciitaj uzantoj.", + "posting": "Afiŝante", + "scope": { + "direct": "Rekta – Afiŝi nur al menciitaj uzantoj", + "private": "Nur abonantoj – Afiŝi nur al abonantoj", + "public": "Publika – Afiŝi al publikaj tempolinioj", + "unlisted": "Nelistigita – Ne afiŝi al publikaj tempolinioj" + } }, "registration": { "bio": "Priskribo", "email": "Retpoŝtadreso", "fullname": "Vidiga nomo", "password_confirm": "Konfirmo de pasvorto", - "registration": "Registriĝo" + "registration": "Registriĝo", + "token": "Invita ĵetono", + "captcha": "TESTO DE HOMECO", + "new_captcha": "Alklaku la bildon por akiri novan teston", + "username_placeholder": "ekz. lain", + "fullname_placeholder": "ekz. Lain Iwakura", + "bio_placeholder": "ekz.\nSaluton, mi estas Lain\nMi estas animea knabino vivante en Japanujo. Eble vi konas min de la retejo « Wired ».", + "validations": { + "username_required": "ne povas resti malplena", + "fullname_required": "ne povas resti malplena", + "email_required": "ne povas resti malplena", + "password_required": "ne povas resti malplena", + "password_confirmation_required": "ne povas resti malplena", + "password_confirmation_match": "samu la pasvorton" + } }, "settings": { + "app_name": "Nomo de aplikaĵo", "attachmentRadius": "Kunsendaĵoj", "attachments": "Kunsendaĵoj", - "autoload": "Ŝalti memfaran ŝarĝadon ĉe subo de paĝo", + "autoload": "Ŝalti memfaran enlegadon ĉe subo de paĝo", "avatar": "Profilbildo", "avatarAltRadius": "Profilbildoj (sciigoj)", "avatarRadius": "Profilbildoj", "background": "Fono", "bio": "Priskribo", + "blocks_tab": "Baroj", "btnRadius": "Butonoj", "cBlue": "Blua (Respondo, abono)", "cGreen": "Verda (Kunhavigo)", "cOrange": "Oranĝa (Ŝato)", "cRed": "Ruĝa (Nuligo)", + "change_password": "Ŝanĝi pasvorton", + "change_password_error": "Okazis eraro dum ŝanĝo de via pasvorto.", + "changed_password": "Pasvorto sukcese ŝanĝiĝis!", + "collapse_subject": "Maletendi afiŝojn kun temoj", + "composing": "Verkante", + "confirm_new_password": "Konfirmu novan pasvorton", "current_avatar": "Via nuna profilbildo", + "current_password": "Nuna pasvorto", "current_profile_banner": "Via nuna profila rubando", + "data_import_export_tab": "Enporto / Elporto de datenoj", + "default_vis": "Implicita videbleca amplekso", + "delete_account": "Forigi konton", + "delete_account_description": "Por ĉiam forigi vian konton kaj ĉiujn viajn mesaĝojn", + "delete_account_error": "Okazis eraro dum forigo de via kanto. Se tio daŭre okazados, bonvolu kontakti la administranton de via nodo.", + "delete_account_instructions": "Entajpu sube vian pasvorton por konfirmi forigon de konto.", + "avatar_size_instruction": "La rekomendata malpleja grando de profilbildoj estas 150×150 bilderoj.", + "export_theme": "Konservi antaŭagordon", "filtering": "Filtrado", - "filtering_explanation": "Ĉiuj statoj kun tiuj ĉi vortoj silentiĝos, po unu linie", + "filtering_explanation": "Ĉiuj statoj kun tiuj ĉi vortoj silentiĝos, po unu linio", + "follow_export": "Abona elporto", + "follow_export_button": "Elporti viajn abonojn al CSV-dosiero", + "follow_export_processing": "Traktante; baldaŭ vi ricevos peton elŝuti la dosieron", "follow_import": "Abona enporto", "follow_import_error": "Eraro enportante abonojn", "follows_imported": "Abonoj enportiĝis! Traktado daŭros iom.", "foreground": "Malfono", + "general": "Ĝenerala", "hide_attachments_in_convo": "Kaŝi kunsendaĵojn en interparoloj", "hide_attachments_in_tl": "Kaŝi kunsendaĵojn en tempolinio", + "max_thumbnails": "Plej multa nombro da bildetoj po afiŝo", + "hide_isp": "Kaŝi nodo-propran breton", + "preload_images": "Antaŭ-enlegi bildojn", + "use_one_click_nsfw": "Malfermi konsternajn kunsendaĵojn per nur unu klako", + "hide_post_stats": "Kaŝi statistikon de afiŝoj (ekz. nombron da ŝatoj)", + "hide_user_stats": "Kaŝi statistikon de uzantoj (ekz. nombron da abonantoj)", + "hide_filtered_statuses": "Kaŝi filtritajn statojn", "import_followers_from_a_csv_file": "Enporti abonojn el CSV-dosiero", + "import_theme": "Enlegi antaŭagordojn", + "inputRadius": "Enigaj kampoj", + "checkboxRadius": "Markbutonoj", + "instance_default": "(implicita: {value})", + "instance_default_simple": "(implicita)", + "interface": "Fasado", + "interfaceLanguage": "Lingvo de fasado", + "invalid_theme_imported": "La elektita dosiero ne estas subtenata haŭto de Pleromo. Neniuj ŝanĝoj al via haŭto okazis.", + "limited_availability": "Nehavebla en via foliumilo", "links": "Ligiloj", + "lock_account_description": "Limigi vian konton al nur abonantoj aprobitaj", + "loop_video": "Ripetadi filmojn", + "loop_video_silent_only": "Ripetadi nur filmojn sen sono (ekz. la \"GIF-ojn\" de Mastodon)", + "mutes_tab": "Silentigoj", + "play_videos_in_modal": "Ludi filmojn rekte en la aŭdvidaĵa spektilo", + "use_contain_fit": "Ne tondi la kunsendaĵon en bildetoj", "name": "Nomo", "name_bio": "Nomo kaj priskribo", + "new_password": "Nova pasvorto", + "notification_visibility": "Montrotaj specoj de sciigoj", + "notification_visibility_follows": "Abonoj", + "notification_visibility_likes": "Ŝatoj", + "notification_visibility_mentions": "Mencioj", + "notification_visibility_repeats": "Ripetoj", + "no_rich_text_description": "Forigi riĉtekstajn formojn de ĉiuj afiŝoj", + "no_blocks": "Neniuj baroj", + "no_mutes": "Neniuj silentigoj", + "hide_follows_description": "Ne montri kiun mi sekvas", + "hide_followers_description": "Ne montri kiu min sekvas", + "show_admin_badge": "Montri la insignon de administranto en mia profilo", + "show_moderator_badge": "Montri la insignon de kontrolanto en mia profilo", "nsfw_clickthrough": "Ŝalti traklakan kaŝon de konsternaj kunsendaĵoj", - "panelRadius": "Paneloj", + "oauth_tokens": "Ĵetonoj de OAuth", + "token": "Ĵetono", + "refresh_token": "Ĵetono de novigo", + "valid_until": "Valida ĝis", + "revoke_token": "Senvalidigi", + "panelRadius": "Bretoj", + "pause_on_unfocused": "Paŭzigi elsendfluon kiam langeto ne estas fokusata", "presets": "Antaŭagordoj", "profile_background": "Profila fono", "profile_banner": "Profila rubando", - "radii_help": "Agordi fasadan rondigon de randoj (rastrumere)", - "reply_link_preview": "Ŝalti respond-ligilan antaŭvidon dum ŝvebo", + "profile_tab": "Profilo", + "radii_help": "Agordi fasadan rondigon de randoj (bildere)", + "replies_in_timeline": "Respondoj en tempolinio", + "reply_link_preview": "Ŝalti respond-ligilan antaŭvidon dum musa ŝvebo", + "reply_visibility_all": "Montri ĉiujn respondojn", + "reply_visibility_following": "Montri nur respondojn por mi aŭ miaj abonatoj", + "reply_visibility_self": "Montri nur respondojn por mi", + "saving_err": "Eraro dum konservo de agordoj", + "saving_ok": "Agordoj konserviĝis", + "security_tab": "Sekureco", + "scope_copy": "Kopii amplekson por respondo (rektaj mesaĝoj ĉiam kopiiĝas)", "set_new_avatar": "Agordi novan profilbildon", "set_new_profile_background": "Agordi novan profilan fonon", "set_new_profile_banner": "Agordi novan profilan rubandon", "settings": "Agordoj", - "stop_gifs": "Movi GIF-bildojn dum ŝvebo", + "subject_input_always_show": "Ĉiam montri teman kampon", + "subject_line_behavior": "Kopii temon por respondo", + "subject_line_email": "Kiel retpoŝto: \"re: temo\"", + "subject_line_mastodon": "Kiel Mastodon: kopii senŝanĝe", + "subject_line_noop": "Ne kopii", + "post_status_content_type": "Afiŝi specon de la enhavo de la stato", + "stop_gifs": "Movi GIF-bildojn dum musa ŝvebo", "streaming": "Ŝalti memfaran fluigon de novaj afiŝoj ĉe la supro de la paĝo", "text": "Teksto", - "theme": "Etoso", - "theme_help": "Uzu deksesumajn kolorkodojn (#rrvvbb) por adapti vian koloran etoson.", + "theme": "Haŭto", + "theme_help": "Uzu deksesumajn kolorkodojn (#rrvvbb) por adapti vian koloran haŭton.", + "theme_help_v2_1": "Vi ankaŭ povas superagordi la kolorojn kaj travideblecon de kelkaj eroj per marko de la markbutono; uzu la butonon \"Vakigi ĉion\" por forigi ĉîujn superagordojn.", + "theme_help_v2_2": "Bildsimboloj sub kelkaj eroj estas indikiloj de kontrasto inter fono kaj teksto; muse ŝvebu por detalaj informoj. Bonvolu memori, ke la indikilo montras la plej malbonan okazeblon dum sia uzo.", "tooltipRadius": "Ŝpruchelpiloj/avertoj", - "user_settings": "Uzantaj agordoj" + "upload_a_photo": "Alŝuti foton", + "user_settings": "Agordoj de uzanto", + "values": { + "false": "ne", + "true": "jes" + }, + "notifications": "Sciigoj", + "enable_web_push_notifications": "Ŝalti retajn puŝajn sciigojn", + "style": { + "switcher": { + "keep_color": "Konservi kolorojn", + "keep_shadows": "Konservi ombrojn", + "keep_opacity": "Konservi maltravideblecon", + "keep_roundness": "Konservi rondecon", + "keep_fonts": "Konservi tiparojn", + "save_load_hint": "Elektebloj de \"konservi\" konservas la nuntempajn agordojn dum elektado aŭ enlegado de haŭtoj. Ĝi ankaŭ konservas tiujn agordojn dum elportado de haŭto. Kun ĉiuj markbutonoj nemarkitaj, elporto de la haŭto ĉion konservos.", + "reset": "Restarigi", + "clear_all": "Vakigi ĉion", + "clear_opacity": "Vakigi maltravideblecon" + }, + "common": { + "color": "Koloro", + "opacity": "Maltravidebleco", + "contrast": { + "hint": "Proporcio de kontrasto estas {ratio}, ĝi {level} {context}", + "level": { + "aa": "plenumas la gvidilon je nivelo AA (malpleja)", + "aaa": "plenumas la gvidilon je nivela AAA (rekomendita)", + "bad": "plenumas neniujn faciluzajn gvidilojn" + }, + "context": { + "18pt": "por granda (18pt+) teksto", + "text": "por teksto" + } + } + }, + "common_colors": { + "_tab_label": "Komunaj", + "main": "Komunaj koloroj", + "foreground_hint": "Vidu langeton \"Specialaj\" por pli detalaj agordoj", + "rgbo": "Bildsimboloj, emfazoj, insignoj" + }, + "advanced_colors": { + "_tab_label": "Specialaj", + "alert": "Averta fono", + "alert_error": "Eraro", + "badge": "Insigna fono", + "badge_notification": "Sciigo", + "panel_header": "Kapo de breto", + "top_bar": "Supra breto", + "borders": "Limoj", + "buttons": "Butonoj", + "inputs": "Enigaj kampoj", + "faint_text": "Malvigla teksto" + }, + "radii": { + "_tab_label": "Rondeco" + }, + "shadows": { + "_tab_label": "Ombro kaj lumo", + "component": "Ero", + "override": "Transpasi", + "shadow_id": "Ombro #{value}", + "blur": "Malklarigo", + "spread": "Vastigo", + "inset": "Internigo", + "hint": "Por ombroj vi ankaŭ povas uzi --variable kiel koloran valoron, por uzi variantojn de CSS3. Bonvolu rimarki, ke tiuokaze agordoj de maltravidebleco ne funkcios.", + "filter_hint": { + "always_drop_shadow": "Averto: ĉi tiu ombro ĉiam uzas {0} kiam la foliumilo ĝin subtenas.", + "drop_shadow_syntax": "{0} ne subtenas parametron {1} kaj ŝlosilvorton {2}.", + "avatar_inset": "Bonvolu rimarki, ke agordi ambaŭ internajn kaj eksterajn ombrojn por profilbildoj povas redoni neatenditajn rezultojn ĉe profilbildoj travideblaj.", + "spread_zero": "Ombroj kun vastigo > 0 aperos kvazaŭ ĝi estus fakte nulo", + "inset_classic": "Internaj ombroj uzos {0}" + }, + "components": { + "panel": "Breto", + "panelHeader": "Kapo de breto", + "topBar": "Supra breto", + "avatar": "Profilbildo de uzanto (en profila vido)", + "avatarStatus": "Profilbildo de uzanto (en afiŝa vido)", + "popup": "Ŝprucaĵoj", + "button": "Butono", + "buttonHover": "Butono (je ŝvebo)", + "buttonPressed": "Butono (premita)", + "buttonPressedHover": "Butono (premita je ŝvebo)", + "input": "Eniga kampo" + } + }, + "fonts": { + "_tab_label": "Tiparoj", + "help": "Elektu tiparon uzotan por eroj de la fasado. Por \"propra\" vi devas enigi la precizan nomon de tiparo tiel, kiel ĝi aperas en la sistemo", + "components": { + "interface": "Fasado", + "input": "Enigaj kampoj", + "post": "Teksto de afiŝo", + "postCode": "Egallarĝa teksto en afiŝo (riĉteksto)" + }, + "family": "Nomo de tiparo", + "size": "Grando (en bilderoj)", + "weight": "Pezo (graseco)", + "custom": "Propra" + }, + "preview": { + "header": "Antaŭrigardo", + "content": "Enhavo", + "error": "Ekzempla eraro", + "button": "Butono", + "text": "Kelko da pliaj {0} kaj {1}", + "mono": "enhavo", + "input": "Ĵus alvenis al la Universala Kongreso!", + "faint_link": "helpan manlibron", + "fine_print": "Legu nian {0} por nenion utilan ekscii!", + "header_faint": "Tio estas en ordo", + "checkbox": "Mi legetis la kondiĉojn de uzado", + "link": "bela eta ligil’" + } + } }, "timeline": { "collapse": "Maletendi", "conversation": "Interparolo", "error_fetching": "Eraro dum ĝisdatigo", "load_older": "Montri pli malnovajn statojn", - "repeated": "ripetata", + "no_retweet_hint": "Afiŝo estas markita kiel rekta aŭ nur por abonantoj, kaj ne eblas ĝin ripeti", + "repeated": "ripetita", "show_new": "Montri novajn", - "up_to_date": "Ĝisdata" + "up_to_date": "Ĝisdata", + "no_more_statuses": "Neniuj pliaj statoj", + "no_statuses": "Neniuj statoj" }, "user_card": { + "approve": "Aprobi", "block": "Bari", "blocked": "Barita!", + "deny": "Rifuzi", + "favorites": "Ŝatataj", "follow": "Aboni", + "follow_sent": "Peto sendiĝis!", + "follow_progress": "Petanta…", + "follow_again": "Ĉu sendi peton denove?", + "follow_unfollow": "Malaboni", "followees": "Abonatoj", "followers": "Abonantoj", "following": "Abonanta!", "follows_you": "Abonas vin!", + "its_you": "Tio estas vi!", + "media": "Aŭdvidaĵoj", "mute": "Silentigi", "muted": "Silentigitaj", "per_day": "tage", "remote_follow": "Fore aboni", - "statuses": "Statoj" + "statuses": "Statoj", + "unblock": "Malbari", + "unblock_progress": "Malbaranta…", + "block_progress": "Baranta…", + "unmute": "Malsilentigi", + "unmute_progress": "Malsilentiganta…", + "mute_progress": "Silentiganta…" }, "user_profile": { - "timeline_title": "Uzanta tempolinio" + "timeline_title": "Uzanta tempolinio", + "profile_does_not_exist": "Pardonu, ĉi tiu profilo ne ekzistas.", + "profile_loading_error": "Pardonu, eraro okazis dum enlegado de ĉi tiu profilo." + }, + "who_to_follow": { + "more": "Pli", + "who_to_follow": "Kiun aboni" + }, + "tool_tip": { + "media_upload": "Alŝuti aŭdvidaĵon", + "repeat": "Ripeti", + "reply": "Respondi", + "favorite": "Ŝati", + "user_settings": "Agordoj de uzanto" + }, + "upload":{ + "error": { + "base": "Alŝuto malsukcesis.", + "file_too_big": "Dosiero estas tro granda [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]", + "default": "Reprovu pli poste" + }, + "file_size_units": { + "B": "B", + "KiB": "KiB", + "MiB": "MiB", + "GiB": "GiB", + "TiB": "TiB" + } } } diff --git a/src/i18n/es.json b/src/i18n/es.json index d14e7a31..fe96dd08 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -171,6 +171,11 @@ "show_admin_badge": "Mostrar la placa de administrador en mi perfil", "show_moderator_badge": "Mostrar la placa de moderador en mi perfil", "nsfw_clickthrough": "Activar el clic para ocultar los adjuntos NSFW", + "oauth_tokens": "Tokens de OAuth", + "token": "Token", + "refresh_token": "Actualizar el token", + "valid_until": "Válido hasta", + "revoke_token": "Revocar", "panelRadius": "Paneles", "pause_on_unfocused": "Parar la transmisión cuando no estés en foco.", "presets": "Por defecto", @@ -197,7 +202,6 @@ "subject_line_mastodon": "Tipo mastodon: copiar como es", "subject_line_noop": "No copiar", "post_status_content_type": "Formato de publicación", - "status_content_type_plain": "Texto plano", "stop_gifs": "Iniciar GIFs al pasar el ratón", "streaming": "Habilite la transmisión automática de nuevas publicaciones cuando se desplaza hacia la parte superior", "text": "Texto", diff --git a/src/i18n/fi.json b/src/i18n/fi.json index 5a0c1ea8..4f0ffb4b 100644 --- a/src/i18n/fi.json +++ b/src/i18n/fi.json @@ -133,6 +133,7 @@ "general": "Yleinen", "hide_attachments_in_convo": "Piilota liitteet keskusteluissa", "hide_attachments_in_tl": "Piilota liitteet aikajanalla", + "max_thumbnails": "Suurin sallittu määrä liitteitä esikatselussa", "hide_isp": "Piilota palvelimenkohtainen ruutu", "preload_images": "Esilataa kuvat", "use_one_click_nsfw": "Avaa NSFW-liitteet yhdellä painalluksella", @@ -165,6 +166,11 @@ "no_rich_text_description": "Älä näytä tekstin muotoilua.", "hide_network_description": "Älä näytä seurauksiani tai seuraajiani", "nsfw_clickthrough": "Piilota NSFW liitteet klikkauksen taakse", + "oauth_tokens": "OAuth-merkit", + "token": "Token", + "refresh_token": "Päivitä token", + "valid_until": "Voimassa asti", + "revoke_token": "Peruuttaa", "panelRadius": "Ruudut", "pause_on_unfocused": "Pysäytä automaattinen viestien näyttö välilehden ollessa pois fokuksesta", "presets": "Valmiit teemat", @@ -215,6 +221,10 @@ "up_to_date": "Ajantasalla", "no_more_statuses": "Ei enempää viestejä" }, + "status": { + "reply_to": "Vastaus", + "replies_list": "Vastaukset:" + }, "user_card": { "approve": "Hyväksy", "block": "Estä", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 129b7d7c..1209556a 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -137,6 +137,11 @@ "notification_visibility_mentions": "Mentionnés", "notification_visibility_repeats": "Partages", "nsfw_clickthrough": "Masquer les images marquées comme contenu adulte ou sensible", + "oauth_tokens": "Jetons OAuth", + "token": "Jeton", + "refresh_token": "Refresh Token", + "valid_until": "Valable jusque", + "revoke_token": "Révoquer", "panelRadius": "Fenêtres", "pause_on_unfocused": "Suspendre le streaming lorsque l'onglet n'est pas centré", "presets": "Thèmes prédéfinis", diff --git a/src/i18n/ga.json b/src/i18n/ga.json index 64461202..5be9297a 100644 --- a/src/i18n/ga.json +++ b/src/i18n/ga.json @@ -134,6 +134,11 @@ "notification_visibility_repeats": "Atphostáil", "no_rich_text_description": "Bain formáidiú téacs saibhir ó gach post", "nsfw_clickthrough": "Cumasaigh an ceangaltán NSFW cliceáil ar an gcnaipe", + "oauth_tokens": "Tocanna OAuth", + "token": "Token", + "refresh_token": "Athnuachan Comórtas", + "valid_until": "Bailí Go dtí", + "revoke_token": "Athghairm", "panelRadius": "Painéil", "pause_on_unfocused": "Sruthú ar sos nuair a bhíonn an fócas caillte", "presets": "Réamhshocruithe", diff --git a/src/i18n/he.json b/src/i18n/he.json index 99ae9551..213e6170 100644 --- a/src/i18n/he.json +++ b/src/i18n/he.json @@ -129,6 +129,11 @@ "notification_visibility_mentions": "אזכורים", "notification_visibility_repeats": "חזרות", "nsfw_clickthrough": "החל החבאת צירופים לא בטוחים לצפיה בעת עבודה בעזרת לחיצת עכבר", + "oauth_tokens": "אסימוני OAuth", + "token": "אסימון", + "refresh_token": "רענון האסימון", + "valid_until": "בתוקף עד", + "revoke_token": "בטל", "panelRadius": "פאנלים", "pause_on_unfocused": "השהה זרימת הודעות כשהחלון לא בפוקוס", "presets": "ערכים קבועים מראש", diff --git a/src/i18n/it.json b/src/i18n/it.json index 8f69e7c1..385d21aa 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -93,6 +93,11 @@ "notification_visibility_mentions": "Menzioni", "notification_visibility_repeats": "Condivisioni", "no_rich_text_description": "Togli la formattazione del testo da tutti i post", + "oauth_tokens": "Token OAuth", + "token": "Token", + "refresh_token": "Aggiorna token", + "valid_until": "Valido fino a", + "revoke_token": "Revocare", "panelRadius": "Pannelli", "pause_on_unfocused": "Metti in pausa l'aggiornamento continuo quando la scheda non è in primo piano", "presets": "Valori predefiniti", diff --git a/src/i18n/ja.json b/src/i18n/ja.json index afce03a4..f39a5a7c 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -17,7 +17,9 @@ }, "general": { "apply": "てきよう", - "submit": "そうしん" + "submit": "そうしん", + "more": "つづき", + "generic_error": "エラーになりました" }, "login": { "login": "ログイン", @@ -26,7 +28,8 @@ "password": "パスワード", "placeholder": "れい: lain", "register": "はじめる", - "username": "ユーザーめい" + "username": "ユーザーめい", + "hint": "はなしあいにくわわるには、ログインしてください" }, "nav": { "about": "これはなに?", @@ -49,7 +52,8 @@ "load_older": "ふるいつうちをみる", "notifications": "つうち", "read": "よんだ!", - "repeated_you": "あなたのステータスがリピートされました" + "repeated_you": "あなたのステータスがリピートされました", + "no_more_notifications": "つうちはありません" }, "post_status": { "new_status": "とうこうする", @@ -117,6 +121,7 @@ "delete_account_description": "あなたのアカウントとメッセージが、きえます。", "delete_account_error": "アカウントをけすことが、できなかったかもしれません。インスタンスのかんりしゃに、れんらくしてください。", "delete_account_instructions": "ほんとうにアカウントをけしてもいいなら、パスワードをかいてください。", + "avatar_size_instruction": "アバターのおおきさは、150×150ピクセルか、それよりもおおきくするといいです。", "export_theme": "セーブ", "filtering": "フィルタリング", "filtering_explanation": "これらのことばをふくむすべてのものがミュートされます。1ぎょうに1つのことばをかいてください。", @@ -132,8 +137,10 @@ "hide_attachments_in_tl": "タイムラインのファイルをかくす", "hide_isp": "インスタンススペシフィックパネルをかくす", "preload_images": "がぞうをさきよみする", + "use_one_click_nsfw": "NSFWなファイルを1クリックでひらく", "hide_post_stats": "とうこうのとうけいをかくす (れい: おきにいりのかず)", "hide_user_stats": "ユーザーのとうけいをかくす (れい: フォロワーのかず)", + "hide_filtered_statuses": "フィルターされたとうこうをかくす", "import_followers_from_a_csv_file": "CSVファイルからフォローをインポートする", "import_theme": "ロード", "inputRadius": "インプットフィールド", @@ -148,6 +155,8 @@ "lock_account_description": "あなたがみとめたひとだけ、あなたのアカウントをフォローできる", "loop_video": "ビデオをくりかえす", "loop_video_silent_only": "おとのないビデオだけくりかえす", + "play_videos_in_modal": "ビデオをメディアビューアーでみる", + "use_contain_fit": "がぞうのサムネイルを、きりぬかない", "name": "なまえ", "name_bio": "なまえとプロフィール", "new_password": "あたらしいパスワード", @@ -157,9 +166,16 @@ "notification_visibility_mentions": "メンション", "notification_visibility_repeats": "リピート", "no_rich_text_description": "リッチテキストをつかわない", - "hide_follows_description": "フォローしている人を表示しない", - "hide_followers_description": "フォローしている人を表示しない", + "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": "プリセット", @@ -185,6 +201,7 @@ "subject_line_email": "メールふう: \"re: サブジェクト\"", "subject_line_mastodon": "マストドンふう: そのままコピー", "subject_line_noop": "コピーしない", + "post_status_content_type": "とうこうのコンテントタイプ", "stop_gifs": "カーソルをかさねたとき、GIFをうごかす", "streaming": "うえまでスクロールしたとき、じどうてきにストリーミングする", "text": "もじ", @@ -318,13 +335,15 @@ "no_retweet_hint": "とうこうを「フォロワーのみ」または「ダイレクト」にすると、リピートできなくなります", "repeated": "リピート", "show_new": "よみこみ", - "up_to_date": "さいしん" + "up_to_date": "さいしん", + "no_more_statuses": "これでおわりです" }, "user_card": { "approve": "うけいれ", "block": "ブロック", "blocked": "ブロックしています!", "deny": "おことわり", + "favorites": "おきにいり", "follow": "フォロー", "follow_sent": "リクエストを、おくりました!", "follow_progress": "リクエストしています…", @@ -335,6 +354,7 @@ "following": "フォローしています!", "follows_you": "フォローされました!", "its_you": "これはあなたです!", + "media": "メディア", "mute": "ミュート", "muted": "ミュートしています!", "per_day": "/日", diff --git a/src/i18n/ko.json b/src/i18n/ko.json index f9e4dfa3..336e464f 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -159,6 +159,11 @@ "hide_follows_description": "내가 팔로우하는 사람을 표시하지 않음", "hide_followers_description": "나를 따르는 사람을 보여주지 마라.", "nsfw_clickthrough": "NSFW 이미지 \"클릭해서 보이기\"를 활성화", + "oauth_tokens": "OAuth 토큰", + "token": "토큰", + "refresh_token": "토큰 새로 고침", + "valid_until": "까지 유효하다", + "revoke_token": "취소", "panelRadius": "패널", "pause_on_unfocused": "탭이 활성 상태가 아닐 때 스트리밍 멈추기", "presets": "프리셋", diff --git a/src/i18n/messages.js b/src/i18n/messages.js index 1adadc32..ab697948 100644 --- a/src/i18n/messages.js +++ b/src/i18n/messages.js @@ -10,6 +10,7 @@ const messages = { ar: require('./ar.json'), ca: require('./ca.json'), + cs: require('./cs.json'), de: require('./de.json'), en: require('./en.json'), eo: require('./eo.json'), diff --git a/src/i18n/nb.json b/src/i18n/nb.json index 0f4dca58..39e054f7 100644 --- a/src/i18n/nb.json +++ b/src/i18n/nb.json @@ -132,6 +132,11 @@ "notification_visibility_repeats": "Gjentakelser", "no_rich_text_description": "Fjern all formatering fra statuser", "nsfw_clickthrough": "Krev trykk for å vise statuser som kan være upassende", + "oauth_tokens": "OAuth Tokens", + "token": "Pollett", + "refresh_token": "Refresh Token", + "valid_until": "Gyldig til", + "revoke_token": "Tilbakekall", "panelRadius": "Panel", "pause_on_unfocused": "Stopp henting av poster når vinduet ikke er i fokus", "presets": "Forhåndsdefinerte tema", diff --git a/src/i18n/nl.json b/src/i18n/nl.json index bb388a90..799e22b9 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -159,6 +159,11 @@ "no_rich_text_description": "Strip rich text formattering van alle posts", "hide_network_description": "Toon niet wie mij volgt en wie ik volg.", "nsfw_clickthrough": "Schakel doorklikbaar verbergen van NSFW bijlages in", + "oauth_tokens": "OAuth-tokens", + "token": "Token", + "refresh_token": "Token vernieuwen", + "valid_until": "Geldig tot", + "revoke_token": "Intrekken", "panelRadius": "Panelen", "pause_on_unfocused": "Pauzeer streamen wanneer de tab niet gefocused is", "presets": "Presets", diff --git a/src/i18n/oc.json b/src/i18n/oc.json index 2ce666c6..fd5ccc97 100644 --- a/src/i18n/oc.json +++ b/src/i18n/oc.json @@ -2,50 +2,81 @@ "chat": { "title": "Messatjariá" }, + "features_panel": { + "chat": "Chat", + "gopher": "Gopher", + "media_proxy": "Servidor mandatari mèdia", + "scope_options": "Nivèls de confidencialitat", + "text_limit": "Limita de tèxte", + "title": "Foncionalitats", + "who_to_follow": "Qual seguir" + }, "finder": { - "error_fetching_user": "Error pendent la recèrca d’un utilizaire", + "error_fetching_user": "Error pendent la cèrca d’un utilizaire", "find_user": "Cercar un utilizaire" }, "general": { "apply": "Aplicar", - "submit": "Mandar" + "submit": "Mandar", + "more": "Mai", + "generic_error": "Una error s’es producha", + "optional": "opcional" + }, + "image_cropper": { + "crop_picture": "Talhar l’imatge", + "save": "Salvar", + "cancel": "Anullar" }, "login": { "login": "Connexion", + "description": "Connexion via OAuth", "logout": "Desconnexion", "password": "Senhal", "placeholder": "e.g. lain", "register": "Se marcar", - "username": "Nom d’utilizaire" + "username": "Nom d’utilizaire", + "hint": "Connectatz-vos per participar a la discutida" + }, + "media_modal": { + "previous": "Precedent", + "next": "Seguent" }, "nav": { + "about": "A prepaus", + "back": "Tornar", "chat": "Chat local", + "friend_requests": "Demandas de seguiment", "mentions": "Notificacions", + "dms": "Messatges privats", "public_tl": "Estatuts locals", "timeline": "Flux d’actualitat", "twkn": "Lo malhum conegut", - "friend_requests": "Demandas d'abonament" + "user_search": "Cèrca d’utilizaires", + "who_to_follow": "Qual seguir", + "preferences": "Preferéncias" }, "notifications": { + "broken_favorite": "Estatut desconegut, sèm a lo cercar...", "favorited_you": "a aimat vòstre estatut", "followed_you": "vos a seguit", + "load_older": "Cargar las notificaciones mai ancianas", "notifications": "Notficacions", - "read": "Legit !", + "read": "Legit !", "repeated_you": "a repetit vòstre estatut", - "broken_favorite": "Estatut desconegut, sèm a lo cercar...", - "load_older": "Cargar las notificaciones mai ancianas" + "no_more_notifications": "Pas mai de notificacions" }, "post_status": { - "content_warning": "Avís de contengut (opcional)", - "default": "Escrivètz aquí vòstre estatut.", - "posting": "Mandadís", + "new_status": "Publicar d’estatuts novèls", "account_not_locked_warning": "Vòstre compte es pas {0}. Qual que siá pòt vos seguir per veire vòstras publicacions destinadas pas qu'a vòstres seguidors.", "account_not_locked_warning_link": "clavat", "attachments_sensitive": "Marcar las pèças juntas coma sensiblas", "content_type": { "plain_text": "Tèxte brut" }, + "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.", + "posting": "Mandadís", "scope": { "direct": "Dirècte - Publicar pels utilizaires mencionats solament", "private": "Seguidors solament - Publicar pels sols seguidors", @@ -59,9 +90,23 @@ "fullname": "Nom complèt", "password_confirm": "Confirmar lo senhal", "registration": "Inscripcion", - "token": "Geton de convidat" + "token": "Geton de convidat", + "captcha": "CAPTCHA", + "new_captcha": "Clicatz l’imatge per obténer una nòva captcha", + "username_placeholder": "e.g. lain", + "fullname_placeholder": "e.g. Lain Iwakura", + "bio_placeholder": "e.g.\nHi, Soi lo Lain\nSoi afocada d’animes e vivi al Japan. Benlèu que me coneissètz de the Wired.", + "validations": { + "username_required": "pòt pas èsser void", + "fullname_required": "pòt pas èsser void", + "email_required": "pòt pas èsser void", + "password_required": "pòt pas èsser void", + "password_confirmation_required": "pòt pas èsser void", + "password_confirmation_match": "deu èsser lo meteis senhal" + } }, "settings": { + "app_name": "Nom de l’aplicacion", "attachmentRadius": "Pèças juntas", "attachments": "Pèças juntas", "autoload": "Activar lo cargament automatic un còp arribat al cap de la pagina", @@ -70,6 +115,7 @@ "avatarRadius": "Avatars", "background": "Rèire plan", "bio": "Biografia", + "blocks_tab": "Blocatges", "btnRadius": "Botons", "cBlue": "Blau (Respondre, seguir)", "cGreen": "Verd (Repartajar)", @@ -78,15 +124,21 @@ "change_password": "Cambiar lo senhal", "change_password_error": "Una error s’es producha en cambiant lo senhal.", "changed_password": "Senhal corrèctament cambiat !", + "collapse_subject": "Replegar las publicacions amb de subjèctes", + "composing": "Escritura", "confirm_new_password": "Confirmatz lo nòu senhal", "current_avatar": "Vòstre avatar actual", "current_password": "Senhal actual", "current_profile_banner": "Bandièra actuala del perfil", + "data_import_export_tab": "Importar / Exportar las donadas", + "default_vis": "Nivèl de visibilitat per defaut", "delete_account": "Suprimir lo compte", "delete_account_description": "Suprimir vòstre compte e los messatges per sempre.", "delete_account_error": "Una error s’es producha en suprimir lo compte. S’aquò ten d’arribar mercés de contactar vòstre administrador d’instància.", "delete_account_instructions": "Picatz vòstre senhal dins lo camp tèxte çai-jos per confirmar la supression del compte.", - "filtering": "Filtre", + "avatar_size_instruction": "La talha minimum recomandada pels imatges d’avatar es 150x150 pixèls.", + "export_theme": "Enregistrar la preconfiguracion", + "filtering": "Filtratge", "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", @@ -95,62 +147,90 @@ "follow_import_error": "Error en important los seguidors", "follows_imported": "Seguidors importats. Lo tractament pòt trigar una estona.", "foreground": "Endavant", + "general": "General", "hide_attachments_in_convo": "Rescondre las pèças juntas dins las conversacions", "hide_attachments_in_tl": "Rescondre las pèças juntas", - "import_followers_from_a_csv_file": "Importar los seguidors d’un fichièr csv", - "inputRadius": "Camps tèxte", - "links": "Ligams", - "name": "Nom", - "name_bio": "Nom & Bio", - "new_password": "Nòu senhal", - "nsfw_clickthrough": "Activar lo clic per mostrar los imatges marcats coma pels adults o sensibles", - "panelRadius": "Panèls", - "presets": "Pre-enregistrats", - "profile_background": "Imatge de fons", - "profile_banner": "Bandièra del perfil", - "radii_help": "Configurar los caires arredondits de l’interfàcia (en pixèls)", - "reply_link_preview": "Activar l’apercebut en passar la mirga", - "set_new_avatar": "Cambiar l’avatar", - "set_new_profile_background": "Cambiar l’imatge de fons", - "set_new_profile_banner": "Cambiar de bandièra", - "settings": "Paramètres", - "stop_gifs": "Lançar los GIFs al subrevòl", - "streaming": "Activar lo cargament automatic dels novèls estatus en anar amont", - "text": "Tèxte", - "theme": "Tèma", - "theme_help": "Emplegatz los còdis de color hex (#rrggbb) per personalizar vòstre tèma de color.", - "tooltipRadius": "Astúcias/Alèrta", - "user_settings": "Paramètres utilizaire", - "collapse_subject": "Replegar las publicacions amb de subjèctes", - "data_import_export_tab": "Importar / Exportar las donadas", - "default_vis": "Nivèl de visibilitat per defaut", - "export_theme": "Enregistrar la preconfiguracion", - "general": "General", + "max_thumbnails": "Nombre maximum de vinhetas per publicacion", + "hide_isp": "Amagar lo panèl especial instància", + "preload_images": "Precargar los imatges", + "use_one_click_nsfw": "Dobrir las pèças juntas NSFW amb un clic", "hide_post_stats": "Amagar los estatistics de publicacion (ex. lo ombre de favorits)", "hide_user_stats": "Amagar las estatisticas de l’utilizaire (ex. lo nombre de seguidors)", + "hide_filtered_statuses": "Amagar los estatuts filtrats", + "import_followers_from_a_csv_file": "Importar los seguidors d’un fichièr csv", "import_theme": "Cargar un tèma", - "instance_default": "(defaut : {value})", + "inputRadius": "Camps tèxte", + "checkboxRadius": "Casas de marcar", + "instance_default": "(defaut : {value})", + "instance_default_simple": "(defaut)", + "interface": "Interfàcia", "interfaceLanguage": "Lenga de l’interfàcia", "invalid_theme_imported": "Lo fichièr seleccionat es pas un tèma Pleroma valid. Cap de cambiament es estat fach a vòstre tèma.", "limited_availability": "Pas disponible per vòstre navigador", + "links": "Ligams", "lock_account_description": "Limitar vòstre compte als seguidors acceptats solament", "loop_video": "Bocla vidèo", - "loop_video_silent_only": "Legir en bocla solament las vidèos sens son (coma los « Gifs » de Mastodon)", - "notification_visibility": "Tipes de notificacion de mostrar", + "loop_video_silent_only": "Legir en bocla solament las vidèos sens son (coma los « Gifs » de Mastodon)", + "mutes_tab": "Agamats", + "play_videos_in_modal": "Legir las vidèoas dirèctament dins la visualizaira mèdia", + "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": "Aiman", + "notification_visibility_likes": "Aimar", "notification_visibility_mentions": "Mencions", "notification_visibility_repeats": "Repeticions", + "notification_visibility": "Tipes de notificacion de mostrar", "no_rich_text_description": "Netejar lo format tèxte de totas las publicacions", + "no_blocks": "Cap de blocatge", + "no_mutes": "Cap d’amagat", + "hide_follows_description": "Mostrar pas qual seguissi", + "hide_followers_description": "Mostrar pas qual me seguisson", + "show_admin_badge": "Mostrar lo badge Admin badge al perfil meu", + "show_moderator_badge": "Mostrar lo badge Moderator al perfil meu", + "nsfw_clickthrough": "Activar lo clic per mostrar los imatges marcats coma pels adults o sensibles", + "oauth_tokens": "Listats OAuth", + "token": "Geton", + "refresh_token": "Actualizar lo geton", + "valid_until": "Valid fins a", + "revoke_token": "Revocar", + "panelRadius": "Panèls", "pause_on_unfocused": "Pausar la difusion quand l’onglet es pas seleccionat", + "presets": "Pre-enregistrats", + "profile_background": "Imatge de fons", + "profile_banner": "Bandièra del perfil", "profile_tab": "Perfil", + "radii_help": "Configurar los caires arredondits de l’interfàcia (en pixèls)", "replies_in_timeline": "Responsas del flux", + "reply_link_preview": "Activar l’apercebut en passar la mirga", "reply_visibility_all": "Mostrar totas las responsas", "reply_visibility_following": "Mostrar pas que las responsas que me son destinada a ieu o un utilizaire que seguissi", "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)", "security_tab": "Seguretat", + "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", + "settings": "Paramètres", + "subject_input_always_show": "Totjorn mostrar lo camp de subjècte", + "subject_line_behavior": "Copiar lo subjècte per las responsas", + "subject_line_email": "Coma los corrièls : \"re: subjècte\"", + "subject_line_mastodon": "Coma mastodon : copiar tal coma es", + "subject_line_noop": "Copiar pas", +"post_status_content_type": "Publicar lo tipe de contengut dels estatuts", + "stop_gifs": "Lançar los GIFs al subrevòl", + "streaming": "Activar lo cargament automatic dels novèls estatus en anar amont", + "text": "Tèxt", + "theme": "Tèma", + "theme_help_v2_1": "You can also override certain component's colors and opacity by toggling the checkbox, use \"Clear all\" button to clear all overrides.", + "theme_help_v2_2": "Icons underneath some entries are background/text contrast indicators, hover over for detailed info. Please keep in mind that when using transparency contrast indicators show the worst possible case.", + "theme_help": "Emplegatz los còdis de color hex (#rrggbb) per personalizar vòstre tèma de color.", + "tooltipRadius": "Astúcias/alèrtas", + "upload_a_photo": "Enviar una fotografia", + "user_settings": "Paramètres utilizaire", "values": { "false": "non", "true": "òc" @@ -166,36 +246,67 @@ "up_to_date": "A jorn", "no_retweet_hint": "La publicacion marcada coma pels seguidors solament o dirècte pòt pas èsser repetida" }, + "status": { + "reply_to": "Respondre à", + "replies_list": "Responsas :" + }, "user_card": { + "approve": "Validar", "block": "Blocar", "blocked": "Blocat !", + "deny": "Refusar", + "favorites": "Favorits", "follow": "Seguir", + "follow_sent": "Demanda enviada !", + "follow_progress": "Demanda…", + "follow_again": "Tornar enviar la demanda ?", + "follow_unfollow": "Quitar de seguir", "followees": "Abonaments", "followers": "Seguidors", - "following": "Seguit !", - "follows_you": "Vos sèc !", + "following": "Seguit !", + "follows_you": "Vos sèc !", + "its_you": "Sètz vos !", + "media": "Mèdia", "mute": "Amagar", "muted": "Amagat", "per_day": "per jorn", "remote_follow": "Seguir a distància", "statuses": "Estatuts", - "approve": "Validar", - "deny": "Refusar" + "unblock": "Desblocar", + "unblock_progress": "Desblocatge...", + "block_progress": "Blocatge...", + "unmute": "Tornar mostrar", + "unmute_progress": "Afichatge...", + "mute_progress": "A amagar..." }, "user_profile": { - "timeline_title": "Flux utilizaire" - }, - "features_panel": { - "chat": "Discutida", - "gopher": "Gopher", - "media_proxy": "Servidor mandatari dels mèdias", - "scope_options": "Opcions d'encastres", - "text_limit": "Limit de tèxte", - "title": "Foncionalitats", - "who_to_follow": "Qui seguir" + "timeline_title": "Flux utilizaire", + "profile_does_not_exist": "Aqueste perfil existís pas.", + "profile_loading_error": "Una error s’es producha en cargant aqueste perfil." }, "who_to_follow": { "more": "Mai", - "who_to_follow": "Qui seguir" + "who_to_follow": "Qual seguir" + }, + "tool_tip": { + "media_upload": "Enviar un mèdia", + "repeat": "Repetir", + "reply": "Respondre", + "favorite": "aimar", + "user_settings": "Paramètres utilizaire" + }, + "upload":{ + "error": { + "base": "Mandadís fracassat.", + "file_too_big": "Fichièr tròp grand [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]", + "default": "Tornatz ensajar mai tard" + }, + "file_size_units": { + "B": "o", + "KiB": "Kio", + "MiB": "Mio", + "GiB": "Gio", + "TiB": "Tio" + } } }
\ No newline at end of file diff --git a/src/i18n/pl.json b/src/i18n/pl.json index a3952d4f..2e1d7488 100644 --- a/src/i18n/pl.json +++ b/src/i18n/pl.json @@ -86,6 +86,11 @@ "name_bio": "Imię i bio", "new_password": "Nowe hasło", "nsfw_clickthrough": "Włącz domyślne ukrywanie załączników o treści nieprzyzwoitej (NSFW)", + "oauth_tokens": "Tokeny OAuth", + "token": "Token", + "refresh_token": "Odśwież token", + "valid_until": "Ważne do", + "revoke_token": "Odwołać", "panelRadius": "Panele", "presets": "Gotowe motywy", "profile_background": "Tło profilu", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index 544eacdf..cbc2c9a3 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -2,116 +2,424 @@ "chat": { "title": "Chat" }, + "features_panel": { + "chat": "Chat", + "gopher": "Gopher", + "media_proxy": "Proxy de mídia", + "scope_options": "Opções de privacidade", + "text_limit": "Limite de caracteres", + "title": "Funções", + "who_to_follow": "Quem seguir" + }, "finder": { - "error_fetching_user": "Erro procurando usuário", + "error_fetching_user": "Erro ao procurar usuário", "find_user": "Buscar usuário" }, "general": { "apply": "Aplicar", - "submit": "Enviar" + "submit": "Enviar", + "more": "Mais", + "generic_error": "Houve um erro", + "optional": "opcional" + }, + "image_cropper": { + "crop_picture": "Cortar imagem", + "save": "Salvar", + "cancel": "Cancelar" }, "login": { "login": "Entrar", + "description": "Entrar com OAuth", "logout": "Sair", "password": "Senha", "placeholder": "p.e. lain", "register": "Registrar", - "username": "Usuário" + "username": "Usuário", + "hint": "Entre para participar da discussão" + }, + "media_modal": { + "previous": "Anterior", + "next": "Próximo" }, "nav": { + "about": "Sobre", + "back": "Voltar", "chat": "Chat local", + "friend_requests": "Solicitações de seguidores", "mentions": "Menções", + "dms": "Mensagens diretas", "public_tl": "Linha do tempo pública", "timeline": "Linha do tempo", - "twkn": "Toda a rede conhecida" + "twkn": "Toda a rede conhecida", + "user_search": "Busca de usuário", + "who_to_follow": "Quem seguir", + "preferences": "Preferências" }, "notifications": { + "broken_favorite": "Status desconhecido, buscando...", "favorited_you": "favoritou sua postagem", "followed_you": "seguiu você", + "load_older": "Carregar notificações antigas", "notifications": "Notificações", "read": "Lido!", - "repeated_you": "repetiu sua postagem" + "repeated_you": "repetiu sua postagem", + "no_more_notifications": "Mais nenhuma notificação" }, "post_status": { + "new_status": "Postar novo status", + "account_not_locked_warning": "Sua conta não está {0}. Qualquer pessoa pode te seguir para ver seus posts restritos.", + "account_not_locked_warning_link": "fechada", + "attachments_sensitive": "Marcar anexos como sensíveis", + "content_type": { + "plain_text": "Texto puro" + }, + "content_warning": "Assunto (opcional)", "default": "Acabei de chegar no Rio!", - "posting": "Publicando" + "direct_warning": "Este post será visível apenas para os usuários mencionados.", + "posting": "Publicando", + "scope": { + "direct": "Direto - Enviar somente aos usuários mencionados", + "private": "Apenas para seguidores - Enviar apenas para seguidores", + "public": "Público - Enviar a linhas do tempo públicas", + "unlisted": "Não listado - Não enviar a linhas do tempo públicas" + } }, "registration": { "bio": "Biografia", "email": "Correio eletrônico", "fullname": "Nome para exibição", "password_confirm": "Confirmação de senha", - "registration": "Registro" + "registration": "Registro", + "token": "Código do convite", + "captcha": "CAPTCHA", + "new_captcha": "Clique na imagem para carregar um novo captcha", + "username_placeholder": "p. ex. lain", + "fullname_placeholder": "p. ex. Lain Iwakura", + "bio_placeholder": "e.g.\nOi, sou Lain\nSou uma garota que vive no subúrbio do Japão. Você deve me conhecer da Rede.", + "validations": { + "username_required": "não pode ser deixado em branco", + "fullname_required": "não pode ser deixado em branco", + "email_required": "não pode ser deixado em branco", + "password_required": "não pode ser deixado em branco", + "password_confirmation_required": "não pode ser deixado em branco", + "password_confirmation_match": "deve ser idêntica à senha" + } }, "settings": { + "app_name": "Nome do aplicativo", "attachmentRadius": "Anexos", "attachments": "Anexos", "autoload": "Habilitar carregamento automático quando a rolagem chegar ao fim.", "avatar": "Avatar", "avatarAltRadius": "Avatares (Notificações)", "avatarRadius": "Avatares", - "background": "Plano de Fundo", + "background": "Pano de Fundo", "bio": "Biografia", + "blocks_tab": "Blocos", "btnRadius": "Botões", "cBlue": "Azul (Responder, seguir)", "cGreen": "Verde (Repetir)", "cOrange": "Laranja (Favoritar)", "cRed": "Vermelho (Cancelar)", + "change_password": "Mudar senha", + "change_password_error": "Houve um erro ao modificar sua senha.", + "changed_password": "Senha modificada com sucesso!", + "collapse_subject": "Esconder posts com assunto", + "composing": "Escrevendo", + "confirm_new_password": "Confirmar nova senha", "current_avatar": "Seu avatar atual", + "current_password": "Sua senha atual", "current_profile_banner": "Sua capa de perfil atual", + "data_import_export_tab": "Importação/exportação de dados", + "default_vis": "Opção de privacidade padrão", + "delete_account": "Deletar conta", + "delete_account_description": "Deletar sua conta e mensagens permanentemente.", + "delete_account_error": "Houve um problema ao deletar sua conta. Se ele persistir, por favor entre em contato com o/a administrador/a da instância.", + "delete_account_instructions": "Digite sua senha no campo abaixo para confirmar a exclusão da conta.", + "avatar_size_instruction": "O tamanho mínimo recomendado para imagens de avatar é 150x150 pixels.", + "export_theme": "Salvar predefinições", "filtering": "Filtragem", "filtering_explanation": "Todas as postagens contendo estas palavras serão silenciadas, uma por linha.", - "follow_import": "Importar seguidas", + "follow_export": "Exportar quem você segue", + "follow_export_button": "Exportar quem você segue para um arquivo CSV", + "follow_export_processing": "Processando. Em breve você receberá a solicitação de download do arquivo", + "follow_import": "Importar quem você segue", "follow_import_error": "Erro ao importar seguidores", "follows_imported": "Seguidores importados! O processamento pode demorar um pouco.", "foreground": "Primeiro Plano", + "general": "Geral", "hide_attachments_in_convo": "Ocultar anexos em conversas", "hide_attachments_in_tl": "Ocultar anexos na linha do tempo.", + "max_thumbnails": "Número máximo de miniaturas por post", + "hide_isp": "Esconder painel específico da instância", + "preload_images": "Pré-carregar imagens", + "use_one_click_nsfw": "Abrir anexos sensíveis com um clique", + "hide_post_stats": "Esconder estatísticas de posts (p. ex. número de favoritos)", + "hide_user_stats": "Esconder estatísticas do usuário (p. ex. número de seguidores)", + "hide_filtered_statuses": "Esconder posts filtrados", "import_followers_from_a_csv_file": "Importe seguidores a partir de um arquivo CSV", + "import_theme": "Carregar pré-definição", + "inputRadius": "Campos de entrada", + "checkboxRadius": "Checkboxes", + "instance_default": "(padrão: {value})", + "instance_default_simple": "(padrão)", + "interface": "Interface", + "interfaceLanguage": "Idioma da interface", + "invalid_theme_imported": "O arquivo selecionado não é um tema compatível com o Pleroma. Nenhuma mudança no tema foi feita.", + "limited_availability": "Indisponível para seu navegador", "links": "Links", + "lock_account_description": "Restringir sua conta a seguidores aprovados", + "loop_video": "Repetir vídeos", + "loop_video_silent_only": "Repetir apenas vídeos sem som (como os \"gifs\" do Mastodon)", + "mutes_tab": "Silenciados", + "play_videos_in_modal": "Tocar vídeos diretamente no visualizador de mídia", + "use_contain_fit": "Não cortar o anexo na miniatura", "name": "Nome", "name_bio": "Nome & Biografia", - "nsfw_clickthrough": "Habilitar clique para ocultar anexos NSFW", + "new_password": "Nova senha", + "notification_visibility": "Tipos de notificação para mostrar", + "notification_visibility_follows": "Seguidos", + "notification_visibility_likes": "Favoritos", + "notification_visibility_mentions": "Menções", + "notification_visibility_repeats": "Repetições", + "no_rich_text_description": "Remover formatação de todos os posts", + "no_blocks": "Sem bloqueios", + "no_mutes": "Sem silenciados", + "hide_follows_description": "Não mostrar quem estou seguindo", + "hide_followers_description": "Não mostrar quem me segue", + "show_admin_badge": "Mostrar distintivo de Administrador em meu perfil", + "show_moderator_badge": "Mostrar título de Moderador em meu perfil", + "nsfw_clickthrough": "Habilitar clique para ocultar anexos sensíveis", + "oauth_tokens": "Token OAuth", + "token": "Token", + "refresh_token": "Atualizar Token", + "valid_until": "Válido até", + "revoke_token": "Revogar", "panelRadius": "Paineis", + "pause_on_unfocused": "Parar transmissão quando a aba não estiver em primeiro plano", "presets": "Predefinições", - "profile_background": "Plano de fundo de perfil", + "profile_background": "Pano de fundo de perfil", "profile_banner": "Capa de perfil", + "profile_tab": "Perfil", "radii_help": "Arredondar arestas da interface (em píxeis)", + "replies_in_timeline": "Respostas na linha do tempo", "reply_link_preview": "Habilitar a pré-visualização de link de respostas ao passar o mouse.", + "reply_visibility_all": "Mostrar todas as respostas", + "reply_visibility_following": "Só mostrar respostas direcionadas a mim ou a usuários que sigo", + "reply_visibility_self": "Só mostrar respostas direcionadas a mim", + "saving_err": "Erro ao salvar configurações", + "saving_ok": "Configurações salvas", + "security_tab": "Segurança", + "scope_copy": "Copiar opções de privacidade ao responder (Mensagens diretas sempre copiam)", "set_new_avatar": "Alterar avatar", "set_new_profile_background": "Alterar o plano de fundo de perfil", "set_new_profile_banner": "Alterar capa de perfil", "settings": "Configurações", + "subject_input_always_show": "Sempre mostrar campo de assunto", + "subject_line_behavior": "Copiar assunto ao responder", + "subject_line_email": "Como em email: \"re: assunto\"", + "subject_line_mastodon": "Como o Mastodon: copiar como está", + "subject_line_noop": "Não copiar", + "post_status_content_type": "Postar tipo de conteúdo do status", "stop_gifs": "Reproduzir GIFs ao passar o cursor em cima", "streaming": "Habilitar o fluxo automático de postagens quando ao topo da página", "text": "Texto", "theme": "Tema", "theme_help": "Use cores em código hexadecimal (#rrggbb) para personalizar seu esquema de cores.", - "tooltipRadius": "Dicass/alertas", - "user_settings": "Configurações de Usuário" + "theme_help_v2_1": "Você também pode sobrescrever as cores e opacidade de alguns componentes ao modificar o checkbox, use \"Limpar todos\" para limpar todas as modificações.", + "theme_help_v2_2": "Alguns ícones sob registros são indicadores de fundo/contraste de textos, passe por cima para informações detalhadas. Tenha ciência de que os indicadores de contraste não funcionam muito bem com transparência.", + "tooltipRadius": "Dicas/alertas", + "upload_a_photo": "Enviar uma foto", + "user_settings": "Configurações de Usuário", + "values": { + "false": "não", + "true": "sim" + }, + "notifications": "Notifications", + "enable_web_push_notifications": "Habilitar notificações web push", + "style": { + "switcher": { + "keep_color": "Manter cores", + "keep_shadows": "Manter sombras", + "keep_opacity": "Manter opacidade", + "keep_roundness": "Manter arredondado", + "keep_fonts": "Manter fontes", + "save_load_hint": "Manter as opções preserva as opções atuais ao selecionar ou carregar temas; também salva as opções ao exportar um tempo. Quanto todos os campos estiverem desmarcados, tudo será salvo ao exportar o tema.", + "reset": "Voltar ao padrão", + "clear_all": "Limpar tudo", + "clear_opacity": "Limpar opacidade" + }, + "common": { + "color": "Cor", + "opacity": "Opacidade", + "contrast": { + "hint": "A taxa de contraste é {ratio}, {level} {context}", + "level": { + "aa": "padrão Nível AA (mínimo)", + "aaa": "padrão Nível AAA (recomendado)", + "bad": "nenhum padrão de acessibilidade" + }, + "context": { + "18pt": "para textos longos (18pt+)", + "text": "para texto" + } + } + }, + "common_colors": { + "_tab_label": "Comum", + "main": "Cores Comuns", + "foreground_hint": "Configurações mais detalhadas na aba\"Avançado\"", + "rgbo": "Ícones, acentuação, distintivos" + }, + "advanced_colors": { + "_tab_label": "Avançado", + "alert": "Fundo de alerta", + "alert_error": "Erro", + "badge": "Fundo do distintivo", + "badge_notification": "Notificação", + "panel_header": "Topo do painel", + "top_bar": "Barra do topo", + "borders": "Bordas", + "buttons": "Botões", + "inputs": "Caixas de entrada", + "faint_text": "Texto esmaecido" + }, + "radii": { + "_tab_label": "Arredondado" + }, + "shadows": { + "_tab_label": "Luz e sombra", + "component": "Componente", + "override": "Sobrescrever", + "shadow_id": "Sombra #{value}", + "blur": "Borrado", + "spread": "Difusão", + "inset": "Inserção", + "hint": "Para as sombras você também pode usar --variável como valor de cor para utilizar variáveis do CSS3. Tenha em mente que configurar a opacidade não será possível neste caso.", + "filter_hint": { + "always_drop_shadow": "Atenção, esta sombra sempre utiliza {0} quando compatível com o navegador.", + "drop_shadow_syntax": "{0} não é compatível com o parâmetro {1} e a palavra-chave {2}.", + "avatar_inset": "Tenha em mente que combinar as sombras de inserção e a não-inserção em avatares pode causar resultados inesperados em avatares transparentes.", + "spread_zero": "Sombras com uma difusão > 0 aparecerão como se fossem definidas como 0.", + "inset_classic": "Sombras de inserção utilizarão {0}" + }, + "components": { + "panel": "Painel", + "panelHeader": "Topo do painel", + "topBar": "Barra do topo", + "avatar": "Avatar do usuário (na visualização do perfil)", + "avatarStatus": "Avatar do usuário (na exibição de posts)", + "popup": "Dicas e notificações", + "button": "Botão", + "buttonHover": "Botão (em cima)", + "buttonPressed": "Botão (pressionado)", + "buttonPressedHover": "Botão (pressionado+em cima)", + "input": "Campo de entrada" + } + }, + "fonts": { + "_tab_label": "Fontes", + "help": "Selecionar fonte dos elementos da interface. Para fonte \"personalizada\" você deve entrar exatamente o nome da fonte no sistema.", + "components": { + "interface": "Interface", + "input": "Campo de entrada", + "post": "Postar texto", + "postCode": "Texto monoespaçado em post (formatação rica)" + }, + "family": "Nome da fonte", + "size": "Tamanho (em px)", + "weight": "Peso", + "custom": "Personalizada" + }, + "preview": { + "header": "Pré-visualizar", + "content": "Conteúdo", + "error": "Erro de exemplo", + "button": "Botão", + "text": "Vários {0} e {1}", + "mono": "conteúdo", + "input": "Acabei de chegar no Rio!", + "faint_link": "manual útil", + "fine_print": "Leia nosso {0} para não aprender nada!", + "header_faint": "Está ok!", + "checkbox": "Li os termos e condições", + "link": "um belo link" + } + } }, "timeline": { + "collapse": "Esconder", "conversation": "Conversa", - "error_fetching": "Erro buscando atualizações", + "error_fetching": "Erro ao buscar atualizações", "load_older": "Carregar postagens antigas", + "no_retweet_hint": "Posts apenas para seguidores ou diretos não podem ser repetidos", + "repeated": "Repetido", "show_new": "Mostrar novas", - "up_to_date": "Atualizado" + "up_to_date": "Atualizado", + "no_more_statuses": "Sem mais posts", + "no_statuses": "Sem posts" + }, + "status": { + "reply_to": "Responder a", + "replies_list": "Respostas:" }, "user_card": { + "approve": "Aprovar", "block": "Bloquear", "blocked": "Bloqueado!", + "deny": "Negar", + "favorites": "Favoritos", "follow": "Seguir", + "follow_sent": "Pedido enviado!", + "follow_progress": "Enviando…", + "follow_again": "Enviar solicitação novamente?", + "follow_unfollow": "Deixar de seguir", "followees": "Seguindo", "followers": "Seguidores", "following": "Seguindo!", "follows_you": "Segue você!", + "its_you": "É você!", + "media": "Mídia", "mute": "Silenciar", "muted": "Silenciado", "per_day": "por dia", "remote_follow": "Seguidor Remoto", - "statuses": "Postagens" + "statuses": "Postagens", + "unblock": "Desbloquear", + "unblock_progress": "Desbloqueando...", + "block_progress": "Bloqueando...", + "unmute": "Retirar silêncio", + "unmute_progress": "Retirando silêncio...", + "mute_progress": "Silenciando..." }, "user_profile": { - "timeline_title": "Linha do tempo do usuário" + "timeline_title": "Linha do tempo do usuário", + "profile_does_not_exist": "Desculpe, este perfil não existe.", + "profile_loading_error": "Desculpe, houve um erro ao carregar este perfil." + }, + "who_to_follow": { + "more": "Mais", + "who_to_follow": "Quem seguir" + }, + "tool_tip": { + "media_upload": "Envio de mídia", + "repeat": "Repetir", + "reply": "Responder", + "favorite": "Favoritar", + "user_settings": "Configurações do usuário" + }, + "upload":{ + "error": { + "base": "Falha no envio.", + "file_too_big": "Arquivo grande demais [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]", + "default": "Tente novamente mais tarde" + }, + "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 4b0bd4b4..6799cc96 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -132,6 +132,11 @@ "show_admin_badge": "Показывать значок администратора в моем профиле", "show_moderator_badge": "Показывать значок модератора в моем профиле", "nsfw_clickthrough": "Включить скрытие NSFW вложений", + "oauth_tokens": "OAuth токены", + "token": "Токен", + "refresh_token": "Рефреш токен", + "valid_until": "Годен до", + "revoke_token": "Удалить", "panelRadius": "Панели", "pause_on_unfocused": "Приостановить загрузку когда вкладка не в фокусе", "presets": "Пресеты", diff --git a/src/i18n/zh.json b/src/i18n/zh.json index 7ad23c57..089a98e2 100644 --- a/src/i18n/zh.json +++ b/src/i18n/zh.json @@ -134,6 +134,11 @@ "notification_visibility_repeats": "转发", "no_rich_text_description": "不显示富文本格式", "nsfw_clickthrough": "将不和谐附件隐藏,点击才能打开", + "oauth_tokens": "OAuth令牌", + "token": "代币", + "refresh_token": "刷新令牌", + "valid_until": "有效期至", + "revoke_token": "撤消", "panelRadius": "面板", "pause_on_unfocused": "在离开页面时暂停时间线推送", "presets": "预置", diff --git a/src/lib/persisted_state.js b/src/lib/persisted_state.js index 6f7202ce..e828a74b 100644 --- a/src/lib/persisted_state.js +++ b/src/lib/persisted_state.js @@ -84,12 +84,12 @@ export default function createPersistedState ({ setState(key, reducer(state, paths), storage) .then(success => { if (typeof success !== 'undefined') { - if (mutation.type === 'setOption') { + if (mutation.type === 'setOption' || mutation.type === 'setCurrentUser') { store.dispatch('settingsSaved', { success }) } } }, error => { - if (mutation.type === 'setOption') { + if (mutation.type === 'setOption' || mutation.type === 'setCurrentUser') { store.dispatch('settingsSaved', { error }) } }) diff --git a/src/main.js b/src/main.js index adeb0550..a3265e3a 100644 --- a/src/main.js +++ b/src/main.js @@ -11,6 +11,7 @@ import configModule from './modules/config.js' import chatModule from './modules/chat.js' import oauthModule from './modules/oauth.js' import mediaViewerModule from './modules/media_viewer.js' +import oauthTokensModule from './modules/oauth_tokens.js' import VueTimeago from 'vue-timeago' import VueI18n from 'vue-i18n' @@ -29,8 +30,9 @@ const currentLocale = (window.navigator.language || 'en').split('-')[0] Vue.use(Vuex) Vue.use(VueRouter) Vue.use(VueTimeago, { - locale: currentLocale === 'ja' ? 'ja' : 'en', + locale: currentLocale === 'cs' ? 'cs' : currentLocale === 'ja' ? 'ja' : 'en', locales: { + 'cs': require('../static/timeago-cs.json'), 'en': require('../static/timeago-en.json'), 'ja': require('../static/timeago-ja.json') } @@ -64,7 +66,8 @@ createPersistedState(persistedStateOptions).then((persistedState) => { config: configModule, chat: chatModule, oauth: oauthModule, - mediaViewer: mediaViewerModule + mediaViewer: mediaViewerModule, + oauthTokens: oauthTokensModule }, plugins: [persistedState, pushNotifications], strict: false // Socket modifies itself, let's ignore this for now. diff --git a/src/modules/config.js b/src/modules/config.js index 71f71376..1c30c203 100644 --- a/src/modules/config.js +++ b/src/modules/config.js @@ -8,6 +8,7 @@ const defaultState = { collapseMessageWithSubject: undefined, // instance default hideAttachments: false, hideAttachmentsInConv: false, + maxThumbnails: 16, hideNsfw: true, preloadImage: true, loopVideo: true, diff --git a/src/modules/instance.js b/src/modules/instance.js index 59c6b91c..24c52f9c 100644 --- a/src/modules/instance.js +++ b/src/modules/instance.js @@ -21,7 +21,7 @@ const defaultState = { collapseMessageWithSubject: false, hidePostStats: false, hideUserStats: false, - hideFilteredStatuses: true, + hideFilteredStatuses: false, disableChat: false, scopeCopy: true, subjectLineBehavior: 'email', @@ -37,6 +37,7 @@ const defaultState = { emoji: [], customEmoji: [], restrictedNicknames: [], + postFormats: [], // Feature-set, apparently, not everything here is reported... mediaProxyAvailable: false, diff --git a/src/modules/oauth_tokens.js b/src/modules/oauth_tokens.js new file mode 100644 index 00000000..00ac1431 --- /dev/null +++ b/src/modules/oauth_tokens.js @@ -0,0 +1,26 @@ +const oauthTokens = { + state: { + tokens: [] + }, + actions: { + fetchTokens ({rootState, commit}) { + rootState.api.backendInteractor.fetchOAuthTokens().then((tokens) => { + commit('swapTokens', tokens) + }) + }, + revokeToken ({rootState, commit, state}, id) { + rootState.api.backendInteractor.revokeOAuthToken(id).then((response) => { + if (response.status === 201) { + commit('swapTokens', state.tokens.filter(token => token.id !== id)) + } + }) + } + }, + mutations: { + swapTokens (state, tokens) { + state.tokens = tokens + } + } +} + +export default oauthTokens diff --git a/src/modules/statuses.js b/src/modules/statuses.js index 56619455..96a3549d 100644 --- a/src/modules/statuses.js +++ b/src/modules/statuses.js @@ -10,6 +10,7 @@ const emptyTl = (userId = 0) => ({ visibleStatusesObject: {}, newStatusCount: 0, maxId: 0, + minId: 0, minVisibleId: 0, loading: false, followers: [], @@ -117,16 +118,21 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us const timelineObject = state.timelines[timeline] const maxNew = statuses.length > 0 ? maxBy(statuses, 'id').id : 0 - const older = timeline && maxNew < timelineObject.maxId + const minNew = statuses.length > 0 ? minBy(statuses, 'id').id : 0 + const newer = timeline && maxNew > timelineObject.maxId && statuses.length > 0 + const older = timeline && (minNew < timelineObject.minId || timelineObject.minId === 0) && statuses.length > 0 - if (timeline && !noIdUpdate && statuses.length > 0 && !older) { + if (!noIdUpdate && newer) { timelineObject.maxId = maxNew } + if (!noIdUpdate && older) { + timelineObject.minId = minNew + } // This makes sure that user timeline won't get data meant for other // user. I.e. opening different user profiles makes request which could // return data late after user already viewing different user profile - if (timeline === 'user' && timelineObject.userId !== userId) { + if ((timeline === 'user' || timeline === 'media') && timelineObject.userId !== userId) { return } @@ -255,12 +261,9 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us processor(status) }) - // Keep the visible statuses sorted + // Keep the visible statuses sorted if (timeline) { sortTimeline(timelineObject) - if ((older || timelineObject.minVisibleId <= 0) && statuses.length > 0) { - timelineObject.minVisibleId = minBy(statuses, 'id').id - } } } @@ -296,13 +299,15 @@ const addNewNotifications = (state, { dispatch, notifications, older, visibleNot notifObj.image = action.attachments[0].url } - if (notification.fresh && !state.notifications.desktopNotificationSilence && visibleNotificationTypes.includes(notification.ntype)) { + if (!notification.seen && !state.notifications.desktopNotificationSilence && visibleNotificationTypes.includes(notification.type)) { let notification = new window.Notification(title, notifObj) // Chrome is known for not closing notifications automatically // according to MDN, anyway. setTimeout(notification.close.bind(notification), 5000) } } + } else if (notification.seen) { + state.notifications.idStore[notification.id].seen = true } }) } diff --git a/src/modules/users.js b/src/modules/users.js index 4d56ec6f..093af497 100644 --- a/src/modules/users.js +++ b/src/modules/users.js @@ -72,19 +72,31 @@ export const mutations = { }, // Because frontend doesn't have a reason to keep these stuff in memory // outside of viewing someones user profile. - clearFriendsAndFollowers (state, userKey) { - const user = state.usersObject[userKey] + clearFriends (state, userId) { + const user = state.usersObject[userId] if (!user) { return } user.friends = [] - user.followers = [] user.friendsPage = 0 + }, + clearFollowers (state, userId) { + const user = state.usersObject[userId] + if (!user) { + return + } + user.followers = [] user.followersPage = 0 }, addNewUsers (state, users) { each(users, (user) => mergeOrAdd(state.users, state.usersObject, user)) }, + saveBlocks (state, blockIds) { + state.currentUser.blockIds = blockIds + }, + saveMutes (state, muteIds) { + state.currentUser.muteIds = muteIds + }, setUserForStatus (state, status) { status.user = state.usersObject[status.user.id] }, @@ -134,7 +146,39 @@ const users = { getters, actions: { fetchUser (store, id) { - store.rootState.api.backendInteractor.fetchUser({ id }) + return store.rootState.api.backendInteractor.fetchUser({ id }) + .then((user) => store.commit('addNewUsers', [user])) + }, + fetchBlocks (store) { + return store.rootState.api.backendInteractor.fetchBlocks() + .then((blocks) => { + store.commit('saveBlocks', map(blocks, 'id')) + store.commit('addNewUsers', blocks) + return blocks + }) + }, + blockUser (store, id) { + return store.rootState.api.backendInteractor.blockUser(id) + .then((user) => store.commit('addNewUsers', [user])) + }, + unblockUser (store, id) { + return store.rootState.api.backendInteractor.unblockUser(id) + .then((user) => store.commit('addNewUsers', [user])) + }, + fetchMutes (store) { + return store.rootState.api.backendInteractor.fetchMutes() + .then((mutedUsers) => { + each(mutedUsers, (user) => { user.muted = true }) + store.commit('addNewUsers', mutedUsers) + store.commit('saveMutes', map(mutedUsers, 'id')) + }) + }, + muteUser (store, id) { + return store.state.api.backendInteractor.setUserMute({ id, muted: true }) + .then((user) => store.commit('addNewUsers', [user])) + }, + unmuteUser (store, id) { + return store.state.api.backendInteractor.setUserMute({ id, muted: false }) .then((user) => store.commit('addNewUsers', [user])) }, addFriends ({ rootState, commit }, fetchBy) { @@ -151,20 +195,19 @@ const users = { }) }, addFollowers ({ rootState, commit }, fetchBy) { - return new Promise((resolve, reject) => { - const user = rootState.users.usersObject[fetchBy] - const page = user.followersPage || 1 - rootState.api.backendInteractor.fetchFollowers({ id: user.id, page }) - .then((followers) => { - commit('addFollowers', { id: user.id, followers, page }) - resolve(followers) - }).catch(() => { - reject() - }) - }) + const user = rootState.users.usersObject[fetchBy] + const page = user.followersPage || 1 + return rootState.api.backendInteractor.fetchFollowers({ id: user.id, page }) + .then((followers) => { + commit('addFollowers', { id: user.id, followers, page }) + return followers + }) + }, + clearFriends ({ commit }, userId) { + commit('clearFriends', userId) }, - clearFriendsAndFollowers ({ commit }, userKey) { - commit('clearFriendsAndFollowers', userKey) + clearFollowers ({ commit }, userId) { + commit('clearFollowers', userId) }, registerPushNotifications (store) { const token = store.state.currentUser.credentials @@ -231,8 +274,14 @@ const users = { store.commit('setToken', result.access_token) store.dispatch('loginUser', result.access_token) } else { - let data = await response.json() - let errors = humanizeErrors(JSON.parse(data.error)) + const data = await response.json() + let errors = JSON.parse(data.error) + // replace ap_id with username + if (errors.ap_id) { + errors.username = errors.ap_id + delete errors.ap_id + } + errors = humanizeErrors(errors) store.commit('signUpFailure', errors) throw Error(errors) } @@ -257,6 +306,8 @@ const users = { const user = data // user.credentials = userCredentials user.credentials = accessToken + user.blockIds = [] + user.muteIds = [] commit('setCurrentUser', user) commit('addNewUsers', [user]) @@ -273,11 +324,8 @@ const users = { // Start getting fresh posts. store.dispatch('startFetching', { timeline: 'friends' }) - // Get user mutes and follower info - store.rootState.api.backendInteractor.fetchMutes().then((mutedUsers) => { - each(mutedUsers, (user) => { user.muted = true }) - store.commit('addNewUsers', mutedUsers) - }) + // Get user mutes + store.dispatch('fetchMutes') // Fetch our friends store.rootState.api.backendInteractor.fetchFriends({ id: user.id }) diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js index 92daa04e..2de87026 100644 --- a/src/services/api/api.service.js +++ b/src/services/api/api.service.js @@ -18,6 +18,7 @@ const MENTIONS_URL = '/api/statuses/mentions.json' const DM_TIMELINE_URL = '/api/statuses/dm_timeline.json' const FOLLOWERS_URL = '/api/statuses/followers.json' const FRIENDS_URL = '/api/statuses/friends.json' +const BLOCKS_URL = '/api/statuses/blocks.json' const FOLLOWING_URL = '/api/friendships/create.json' const UNFOLLOWING_URL = '/api/friendships/destroy.json' const QVITTER_USER_PREF_URL = '/api/qvitter/set_profile_pref.json' @@ -46,6 +47,7 @@ const MASTODON_USER_FAVORITES_TIMELINE_URL = '/api/v1/favourites' import { each, map } from 'lodash' import { parseStatus, parseUser, parseNotification } from '../entity_normalizer/entity_normalizer.service.js' import 'whatwg-fetch' +import { StatusCodeError } from '../errors/errors' const oldfetch = window.fetch @@ -243,7 +245,15 @@ const denyUser = ({id, credentials}) => { const fetchUser = ({id, credentials}) => { let url = `${USER_URL}?user_id=${id}` return fetch(url, { headers: authHeaders(credentials) }) - .then((data) => data.json()) + .then((response) => { + return new Promise((resolve, reject) => response.json() + .then((json) => { + if (!response.ok) { + return reject(new StatusCodeError(response.status, json, { url }, response)) + } + return resolve(json) + })) + }) .then((data) => parseUser(data)) } @@ -258,7 +268,7 @@ const fetchFriends = ({id, page, credentials}) => { } const exportFriends = ({id, credentials}) => { - let url = `${FRIENDS_URL}?user_id=${id}&export=true` + let url = `${FRIENDS_URL}?user_id=${id}&all=true` return fetch(url, { headers: authHeaders(credentials) }) .then((data) => data.json()) .then((data) => data.map(parseUser)) @@ -519,6 +529,34 @@ const fetchMutes = ({credentials}) => { }).then((data) => data.json()) } +const fetchBlocks = ({page, credentials}) => { + return fetch(BLOCKS_URL, { + headers: authHeaders(credentials) + }).then((data) => { + if (data.ok) { + return data.json() + } + throw new Error('Error fetching blocks', data) + }) +} + +const fetchOAuthTokens = ({credentials}) => { + const url = '/api/oauth_tokens.json' + + return fetch(url, { + headers: authHeaders(credentials) + }).then((data) => data.json()) +} + +const revokeOAuthToken = ({id, credentials}) => { + const url = `/api/oauth_tokens/${id}` + + return fetch(url, { + headers: authHeaders(credentials), + method: 'DELETE' + }) +} + const suggestions = ({credentials}) => { return fetch(SUGGESTIONS_URL, { headers: authHeaders(credentials) @@ -560,6 +598,9 @@ const apiService = { fetchAllFollowing, setUserMute, fetchMutes, + fetchBlocks, + fetchOAuthTokens, + revokeOAuthToken, register, getCaptcha, updateAvatar, diff --git a/src/services/backend_interactor_service/backend_interactor_service.js b/src/services/backend_interactor_service/backend_interactor_service.js index 80c5cc5e..7e972d7b 100644 --- a/src/services/backend_interactor_service/backend_interactor_service.js +++ b/src/services/backend_interactor_service/backend_interactor_service.js @@ -54,8 +54,8 @@ const backendInteractorService = (credentials) => { return apiService.denyUser({credentials, id}) } - const startFetching = ({timeline, store, userId = false}) => { - return timelineFetcherService.startFetching({timeline, store, credentials, userId}) + const startFetching = ({timeline, store, userId = false, tag}) => { + return timelineFetcherService.startFetching({timeline, store, credentials, userId, tag}) } const setUserMute = ({id, muted = true}) => { @@ -63,7 +63,10 @@ const backendInteractorService = (credentials) => { } const fetchMutes = () => apiService.fetchMutes({credentials}) + const fetchBlocks = (params) => apiService.fetchBlocks({credentials, ...params}) const fetchFollowRequests = () => apiService.fetchFollowRequests({credentials}) + const fetchOAuthTokens = () => apiService.fetchOAuthTokens({credentials}) + const revokeOAuthToken = (id) => apiService.revokeOAuthToken({id, credentials}) const getCaptcha = () => apiService.getCaptcha() const register = (params) => apiService.register(params) @@ -94,6 +97,9 @@ const backendInteractorService = (credentials) => { startFetching, setUserMute, fetchMutes, + fetchBlocks, + fetchOAuthTokens, + revokeOAuthToken, register, getCaptcha, updateAvatar, diff --git a/src/services/component_utils/component_utils.js b/src/services/component_utils/component_utils.js new file mode 100644 index 00000000..77ea14a1 --- /dev/null +++ b/src/services/component_utils/component_utils.js @@ -0,0 +1,10 @@ +import isFunction from 'lodash/isFunction' + +const getComponentOptions = (Component) => (isFunction(Component)) ? Component.options : Component + +const getComponentProps = (Component) => getComponentOptions(Component).props + +export { + getComponentOptions, + getComponentProps +} diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js index 828c48f9..d20ce77f 100644 --- a/src/services/entity_normalizer/entity_normalizer.service.js +++ b/src/services/entity_normalizer/entity_normalizer.service.js @@ -117,6 +117,9 @@ export const parseUser = (data) => { output.statuses_count = data.statuses_count output.friends = [] output.followers = [] + if (data.pleroma) { + output.follow_request_count = data.pleroma.follow_request_count + } return output } diff --git a/src/services/errors/errors.js b/src/services/errors/errors.js new file mode 100644 index 00000000..548f3c68 --- /dev/null +++ b/src/services/errors/errors.js @@ -0,0 +1,14 @@ +export function StatusCodeError (statusCode, body, options, response) { + this.name = 'StatusCodeError' + this.statusCode = statusCode + this.message = statusCode + ' - ' + (JSON && JSON.stringify ? JSON.stringify(body) : body) + this.error = body // legacy attribute + this.options = options + this.response = response + + if (Error.captureStackTrace) { // required for non-V8 environments + Error.captureStackTrace(this) + } +} +StatusCodeError.prototype = Object.create(Error.prototype) +StatusCodeError.prototype.constructor = StatusCodeError diff --git a/src/services/follow_request_fetcher/follow_request_fetcher.service.js b/src/services/follow_request_fetcher/follow_request_fetcher.service.js new file mode 100644 index 00000000..125ff3e1 --- /dev/null +++ b/src/services/follow_request_fetcher/follow_request_fetcher.service.js @@ -0,0 +1,21 @@ +import apiService from '../api/api.service.js' + +const fetchAndUpdate = ({ store, credentials }) => { + return apiService.fetchFollowRequests({ credentials }) + .then((requests) => { + store.commit('setFollowRequests', requests) + }, () => {}) + .catch(() => {}) +} + +const startFetching = ({credentials, store}) => { + fetchAndUpdate({ credentials, store }) + const boundFetchAndUpdate = () => fetchAndUpdate({ credentials, store }) + return setInterval(boundFetchAndUpdate, 10000) +} + +const followRequestFetcher = { + startFetching +} + +export default followRequestFetcher diff --git a/src/services/notifications_fetcher/notifications_fetcher.service.js b/src/services/notifications_fetcher/notifications_fetcher.service.js index b69ec643..3ecdae6a 100644 --- a/src/services/notifications_fetcher/notifications_fetcher.service.js +++ b/src/services/notifications_fetcher/notifications_fetcher.service.js @@ -16,7 +16,17 @@ const fetchAndUpdate = ({store, credentials, older = false}) => { args['until'] = timelineData.minId } } else { - args['since'] = timelineData.maxId + // load unread notifications repeadedly 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) + } + } } args['timeline'] = 'notifications' diff --git a/src/services/timeline_fetcher/timeline_fetcher.service.js b/src/services/timeline_fetcher/timeline_fetcher.service.js index 64f8f468..6f99616f 100644 --- a/src/services/timeline_fetcher/timeline_fetcher.service.js +++ b/src/services/timeline_fetcher/timeline_fetcher.service.js @@ -21,7 +21,7 @@ const fetchAndUpdate = ({store, credentials, timeline = 'friends', older = false const timelineData = rootState.statuses.timelines[camelCase(timeline)] if (older) { - args['until'] = until || timelineData.minVisibleId + args['until'] = until || timelineData.minId } else { args['since'] = timelineData.maxId } |
