Merge branch 'develop' into features/favicons

This commit is contained in:
Shpuld Shpuldson 2020-06-18 16:47:37 +03:00
commit 8a9654b511
140 changed files with 7916 additions and 4330 deletions

View file

@ -6,6 +6,7 @@ import InstanceSpecificPanel from './components/instance_specific_panel/instance
import FeaturesPanel from './components/features_panel/features_panel.vue'
import WhoToFollowPanel from './components/who_to_follow_panel/who_to_follow_panel.vue'
import ChatPanel from './components/chat_panel/chat_panel.vue'
import SettingsModal from './components/settings_modal/settings_modal.vue'
import MediaModal from './components/media_modal/media_modal.vue'
import SideDrawer from './components/side_drawer/side_drawer.vue'
import MobilePostStatusButton from './components/mobile_post_status_button/mobile_post_status_button.vue'
@ -29,6 +30,7 @@ export default {
SideDrawer,
MobilePostStatusButton,
MobileNav,
SettingsModal,
UserReportingModal,
PostStatusModal
},
@ -45,7 +47,8 @@ export default {
}),
created () {
// Load the locale from the storage
this.$i18n.locale = this.$store.getters.mergedConfig.interfaceLanguage
const val = this.$store.getters.mergedConfig.interfaceLanguage
this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val })
window.addEventListener('resize', this.updateMobileState)
},
destroyed () {
@ -99,7 +102,12 @@ export default {
},
showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel },
isMobileLayout () { return this.$store.state.interface.mobileLayout },
privateMode () { return this.$store.state.instance.private }
privateMode () { return this.$store.state.instance.private },
sidebarAlign () {
return {
'order': this.$store.state.instance.sidebarRight ? 99 : 0
}
}
},
methods: {
scrollToTop () {
@ -112,6 +120,9 @@ export default {
onSearchBarToggled (hidden) {
this.searchBarHidden = hidden
},
openSettingsModal () {
this.$store.dispatch('openSettingsModal')
},
updateMobileState () {
const mobileLayout = windowWidth() <= 800
const changed = mobileLayout !== this.isMobileLayout

View file

@ -566,7 +566,7 @@ main-router {
min-height: 0;
box-sizing: border-box;
margin: 0;
margin-left: .25em;
margin-left: .5em;
min-width: 1px;
align-self: stretch;
}
@ -860,51 +860,6 @@ nav {
}
}
.setting-item {
border-bottom: 2px solid var(--fg, $fallback--fg);
margin: 1em 1em 1.4em;
padding-bottom: 1.4em;
> div {
margin-bottom: .5em;
&:last-child {
margin-bottom: 0;
}
}
&:last-child {
border-bottom: none;
padding-bottom: 0;
margin-bottom: 1em;
}
select {
min-width: 10em;
}
textarea {
width: 100%;
max-width: 100%;
height: 100px;
}
.unavailable,
.unavailable i {
color: var(--cRed, $fallback--cRed);
color: $fallback--cRed;
}
.btn {
min-height: 28px;
min-width: 10em;
padding: 0 2em;
}
.number-input {
max-width: 6em;
}
}
.select-multiple {
display: flex;
.option-list {

View file

@ -46,15 +46,16 @@
@toggled="onSearchBarToggled"
@click.stop.native
/>
<router-link
<a
href="#"
class="mobile-hidden"
:to="{ name: 'settings'}"
@click.stop="openSettingsModal"
>
<i
class="button-icon icon-cog nav-icon"
:title="$t('nav.preferences')"
/>
</router-link>
</a>
<a
v-if="currentUser && currentUser.role === 'admin'"
href="/pleroma/admin/#/login-pleroma"
@ -80,7 +81,10 @@
id="content"
class="container underlay"
>
<div class="sidebar-flexer mobile-hidden">
<div
class="sidebar-flexer mobile-hidden"
:style="sidebarAlign"
>
<div class="sidebar-bounds">
<div class="sidebar-scroller">
<div class="sidebar">
@ -122,6 +126,7 @@
<MobilePostStatusButton />
<UserReportingModal />
<PostStatusModal />
<SettingsModal />
<portal-target name="modal" />
</div>
</template>

View file

@ -108,9 +108,9 @@ const setSettings = async ({ apiConfig, staticConfig, store }) => {
copyInstanceOption('subjectLineBehavior')
copyInstanceOption('postContentType')
copyInstanceOption('alwaysShowSubjectInput')
copyInstanceOption('noAttachmentLinks')
copyInstanceOption('showFeaturesPanel')
copyInstanceOption('hideSitename')
copyInstanceOption('sidebarRight')
return store.dispatch('setTheme', config['theme'])
}
@ -241,6 +241,9 @@ const getNodeInfo = async ({ store }) => {
: federation.enabled
})
const accountActivationRequired = metadata.accountActivationRequired
store.dispatch('setInstanceOption', { name: 'accountActivationRequired', value: accountActivationRequired })
const accounts = metadata.staffAccounts
resolveStaffAccounts({ store, accounts })
} else {
@ -304,6 +307,9 @@ const afterStoreSetup = async ({ store, i18n }) => {
getNodeInfo({ store })
])
// Start fetching things that don't need to block the UI
store.dispatch('fetchMutes')
const router = new VueRouter({
mode: 'history',
routes: routes(store),

View file

@ -7,10 +7,8 @@ import Interactions from 'components/interactions/interactions.vue'
import DMs from 'components/dm_timeline/dm_timeline.vue'
import UserProfile from 'components/user_profile/user_profile.vue'
import Search from 'components/search/search.vue'
import Settings from 'components/settings/settings.vue'
import Registration from 'components/registration/registration.vue'
import PasswordReset from 'components/password_reset/password_reset.vue'
import UserSettings from 'components/user_settings/user_settings.vue'
import FollowRequests from 'components/follow_requests/follow_requests.vue'
import OAuthCallback from 'components/oauth_callback/oauth_callback.vue'
import Notifications from 'components/notifications/notifications.vue'
@ -56,12 +54,10 @@ export default (store) => {
{ name: 'external-user-profile', path: '/users/:id', component: UserProfile },
{ name: 'interactions', path: '/users/:username/interactions', component: Interactions, beforeEnter: validateAuthenticatedRoute },
{ name: 'dms', path: '/users/:username/dms', component: DMs, beforeEnter: validateAuthenticatedRoute },
{ name: 'settings', path: '/settings', component: Settings },
{ name: 'registration', path: '/registration', component: Registration },
{ name: 'password-reset', path: '/password-reset', component: PasswordReset, props: true },
{ name: 'registration-token', path: '/registration/:token', component: Registration },
{ name: 'friend-requests', path: '/friend-requests', component: FollowRequests, beforeEnter: validateAuthenticatedRoute },
{ name: 'user-settings', path: '/user-settings', component: UserSettings, beforeEnter: validateAuthenticatedRoute },
{ name: 'notifications', path: '/:username/notifications', component: Notifications, beforeEnter: validateAuthenticatedRoute },
{ name: 'login', path: '/login', component: AuthForm },
{ name: 'chat', path: '/chat', component: ChatPanel, props: () => ({ floating: false }) },

View file

@ -3,7 +3,7 @@ import Popover from '../popover/popover.vue'
const AccountActions = {
props: [
'user'
'user', 'relationship'
],
data () {
return { }

View file

@ -3,22 +3,23 @@
<Popover
trigger="click"
placement="bottom"
:bound-to="{ x: 'container' }"
>
<div
slot="content"
class="account-tools-popover"
>
<div class="dropdown-menu">
<template v-if="user.following">
<template v-if="relationship.following">
<button
v-if="user.showing_reblogs"
v-if="relationship.showing_reblogs"
class="btn btn-default dropdown-item"
@click="hideRepeats"
>
{{ $t('user_card.hide_repeats') }}
</button>
<button
v-if="!user.showing_reblogs"
v-if="!relationship.showing_reblogs"
class="btn btn-default dropdown-item"
@click="showRepeats"
>
@ -30,7 +31,7 @@
/>
</template>
<button
v-if="user.statusnet_blocking"
v-if="relationship.blocking"
class="btn btn-default btn-block dropdown-item"
@click="unblockUser"
>

View file

@ -0,0 +1,41 @@
<template>
<div class="async-component-error">
<div>
<h4>
{{ $t('general.generic_error') }}
</h4>
<p>
{{ $t('general.error_retry') }}
</p>
<button
class="btn"
@click="retry"
>
{{ $t('general.retry') }}
</button>
</div>
</div>
</template>
<script>
export default {
methods: {
retry () {
this.$emit('resetAsyncComponent')
}
}
}
</script>
<style lang="scss">
.async-component-error {
display: flex;
height: 100%;
align-items: center;
justify-content: center;
.btn {
margin: .5em;
padding: .5em 2em;
}
}
</style>

View file

@ -12,7 +12,7 @@
class="basic-user-card-expanded-content"
>
<UserCard
:user="user"
:user-id="user.id"
:rounded="true"
:bordered="true"
/>

View file

@ -11,8 +11,11 @@ const BlockCard = {
user () {
return this.$store.getters.findUser(this.userId)
},
relationship () {
return this.$store.getters.relationship(this.userId)
},
blocked () {
return this.user.statusnet_blocking
return this.relationship.blocking
}
},
components: {

View file

@ -5,9 +5,20 @@ const DomainMuteCard = {
components: {
ProgressButton
},
computed: {
user () {
return this.$store.state.users.currentUser
},
muted () {
return this.user.domainMutes.includes(this.domain)
}
},
methods: {
unmuteDomain () {
return this.$store.dispatch('unmuteDomain', this.domain)
},
muteDomain () {
return this.$store.dispatch('muteDomain', this.domain)
}
}
}

View file

@ -4,6 +4,7 @@
{{ domain }}
</div>
<ProgressButton
v-if="muted"
:click="unmuteDomain"
class="btn btn-default"
>
@ -12,6 +13,16 @@
{{ $t('domain_mute_card.unmute_progress') }}
</template>
</ProgressButton>
<ProgressButton
v-else
:click="muteDomain"
class="btn btn-default"
>
{{ $t('domain_mute_card.mute') }}
<template slot="progress">
{{ $t('domain_mute_card.mute_progress') }}
</template>
</ProgressButton>
</div>
</template>
@ -34,5 +45,9 @@
button {
width: 10em;
}
.autosuggest-results & {
padding-left: 1em;
}
}
</style>

View file

@ -29,17 +29,29 @@ export default data => input => {
export const suggestEmoji = emojis => input => {
const noPrefix = input.toLowerCase().substr(1)
return emojis
.filter(({ displayText }) => displayText.toLowerCase().startsWith(noPrefix))
.filter(({ displayText }) => displayText.toLowerCase().match(noPrefix))
.sort((a, b) => {
let aScore = 0
let bScore = 0
// Make custom emojis a priority
aScore += a.imageUrl ? 10 : 0
bScore += b.imageUrl ? 10 : 0
// An exact match always wins
aScore += a.displayText.toLowerCase() === noPrefix ? 200 : 0
bScore += b.displayText.toLowerCase() === noPrefix ? 200 : 0
// Sort alphabetically
const alphabetically = a.displayText > b.displayText ? 1 : -1
// Prioritize custom emoji a lot
aScore += a.imageUrl ? 100 : 0
bScore += b.imageUrl ? 100 : 0
// Prioritize prefix matches somewhat
aScore += a.displayText.toLowerCase().startsWith(noPrefix) ? 10 : 0
bScore += b.displayText.toLowerCase().startsWith(noPrefix) ? 10 : 0
// Sort by length
aScore -= a.displayText.length
bScore -= b.displayText.length
// Break ties alphabetically
const alphabetically = a.displayText > b.displayText ? 0.5 : -0.5
return bScore - aScore + alphabetically
})

View file

@ -29,6 +29,11 @@ const ExtraButtons = {
this.$store.dispatch('unmuteConversation', this.status.id)
.then(() => this.$emit('onSuccess'))
.catch(err => this.$emit('onError', err.error.error))
},
copyLink () {
navigator.clipboard.writeText(this.statusLink)
.then(() => this.$emit('onSuccess'))
.catch(err => this.$emit('onError', err.error.error))
}
},
computed: {
@ -46,6 +51,9 @@ const ExtraButtons = {
},
canMute () {
return !!this.currentUser
},
statusLink () {
return `${this.$store.state.instance.server}${this.$router.resolve({ name: 'conversation', params: { id: this.status.id } }).href}`
}
}
}

View file

@ -1,11 +1,14 @@
<template>
<Popover
v-if="canDelete || canMute || canPin"
trigger="click"
placement="top"
class="extra-button-popover"
:bound-to="{ x: 'container' }"
>
<div slot="content">
<div
slot="content"
slot-scope="{close}"
>
<div class="dropdown-menu">
<button
v-if="canMute && !status.thread_muted"
@ -23,28 +26,35 @@
</button>
<button
v-if="!status.pinned && canPin"
v-close-popover
class="dropdown-item dropdown-item-icon"
@click.prevent="pinStatus"
@click="close"
>
<i class="icon-pin" /><span>{{ $t("status.pin") }}</span>
</button>
<button
v-if="status.pinned && canPin"
v-close-popover
class="dropdown-item dropdown-item-icon"
@click.prevent="unpinStatus"
@click="close"
>
<i class="icon-pin" /><span>{{ $t("status.unpin") }}</span>
</button>
<button
v-if="canDelete"
v-close-popover
class="dropdown-item dropdown-item-icon"
@click.prevent="deleteStatus"
@click="close"
>
<i class="icon-cancel" /><span>{{ $t("status.delete") }}</span>
</button>
<button
class="dropdown-item dropdown-item-icon"
@click.prevent="copyLink"
@click="close"
>
<i class="icon-share" /><span>{{ $t("status.copy_link") }}</span>
</button>
</div>
</div>
<i

View file

@ -1,6 +1,6 @@
import { requestFollow, requestUnfollow } from '../../services/follow_manipulate/follow_manipulate'
export default {
props: ['user', 'labelFollowing', 'buttonClass'],
props: ['relationship', 'labelFollowing', 'buttonClass'],
data () {
return {
inProgress: false
@ -8,12 +8,12 @@ export default {
},
computed: {
isPressed () {
return this.inProgress || this.user.following
return this.inProgress || this.relationship.following
},
title () {
if (this.inProgress || this.user.following) {
if (this.inProgress || this.relationship.following) {
return this.$t('user_card.follow_unfollow')
} else if (this.user.requested) {
} else if (this.relationship.requested) {
return this.$t('user_card.follow_again')
} else {
return this.$t('user_card.follow')
@ -22,9 +22,9 @@ export default {
label () {
if (this.inProgress) {
return this.$t('user_card.follow_progress')
} else if (this.user.following) {
} else if (this.relationship.following) {
return this.labelFollowing || this.$t('user_card.following')
} else if (this.user.requested) {
} else if (this.relationship.requested) {
return this.$t('user_card.follow_sent')
} else {
return this.$t('user_card.follow')
@ -33,20 +33,20 @@ export default {
},
methods: {
onClick () {
this.user.following ? this.unfollow() : this.follow()
this.relationship.following ? this.unfollow() : this.follow()
},
follow () {
this.inProgress = true
requestFollow(this.user, this.$store).then(() => {
requestFollow(this.relationship.id, this.$store).then(() => {
this.inProgress = false
})
},
unfollow () {
const store = this.$store
this.inProgress = true
requestUnfollow(this.user, store).then(() => {
requestUnfollow(this.relationship.id, store).then(() => {
this.inProgress = false
store.commit('removeStatus', { timeline: 'friends', userId: this.user.id })
store.commit('removeStatus', { timeline: 'friends', userId: this.relationship.id })
})
}
}

View file

@ -18,6 +18,9 @@ const FollowCard = {
},
loggedIn () {
return this.$store.state.users.currentUser
},
relationship () {
return this.$store.getters.relationship(this.user.id)
}
}
}

View file

@ -2,24 +2,24 @@
<basic-user-card :user="user">
<div class="follow-card-content-container">
<span
v-if="!noFollowsYou && user.follows_you"
v-if="isMe || (!noFollowsYou && relationship.followed_by)"
class="faint"
>
{{ isMe ? $t('user_card.its_you') : $t('user_card.follows_you') }}
</span>
<template v-if="!loggedIn">
<div
v-if="!user.following"
v-if="!relationship.following"
class="follow-card-follow-button"
>
<RemoteFollow :user="user" />
</div>
</template>
<template v-else>
<template v-else-if="!isMe">
<FollowButton
:user="user"
class="follow-card-follow-button"
:relationship="relationship"
:label-following="$t('user_card.follow_unfollow')"
class="follow-card-follow-button"
/>
</template>
</div>

View file

@ -1,4 +1,5 @@
import BasicUserCard from '../basic_user_card/basic_user_card.vue'
import { notificationsFromStore } from '../../services/notification_utils/notification_utils.js'
const FollowRequestCard = {
props: ['user'],
@ -6,13 +7,32 @@ const FollowRequestCard = {
BasicUserCard
},
methods: {
findFollowRequestNotificationId () {
const notif = notificationsFromStore(this.$store).find(
(notif) => notif.from_profile.id === this.user.id && notif.type === 'follow_request'
)
return notif && notif.id
},
approveUser () {
this.$store.state.api.backendInteractor.approveUser({ id: this.user.id })
this.$store.dispatch('removeFollowRequest', this.user)
const notifId = this.findFollowRequestNotificationId()
this.$store.dispatch('markSingleNotificationAsSeen', { id: notifId })
this.$store.dispatch('updateNotification', {
id: notifId,
updater: notification => {
notification.type = 'follow'
}
})
},
denyUser () {
const notifId = this.findFollowRequestNotificationId()
this.$store.state.api.backendInteractor.denyUser({ id: this.user.id })
this.$store.dispatch('removeFollowRequest', this.user)
.then(() => {
this.$store.dispatch('dismissNotificationLocal', { id: notifId })
this.$store.dispatch('removeFollowRequest', this.user)
})
}
}
}

View file

@ -78,6 +78,7 @@
video,
canvas {
object-fit: contain;
height: 100%;
}
}

View file

@ -32,7 +32,7 @@ import _ from 'lodash'
export default {
computed: {
languageCodes () {
return Object.keys(languagesObject)
return languagesObject.languages
},
languageNames () {
@ -43,7 +43,6 @@ export default {
get: function () { return this.$store.getters.mergedConfig.interfaceLanguage },
set: function (val) {
this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val })
this.$i18n.locale = val
}
}
},

View file

@ -84,10 +84,12 @@ const MediaModal = {
}
},
mounted () {
window.addEventListener('popstate', this.hide)
document.addEventListener('keyup', this.handleKeyupEvent)
document.addEventListener('keydown', this.handleKeydownEvent)
},
destroyed () {
window.removeEventListener('popstate', this.hide)
document.removeEventListener('keyup', this.handleKeyupEvent)
document.removeEventListener('keydown', this.handleKeydownEvent)
}

View file

@ -5,10 +5,15 @@ import fileSizeFormatService from '../../services/file_size_format/file_size_for
const mediaUpload = {
data () {
return {
uploading: false,
uploadCount: 0,
uploadReady: true
}
},
computed: {
uploading () {
return this.uploadCount > 0
}
},
methods: {
uploadFile (file) {
const self = this
@ -23,29 +28,21 @@ const mediaUpload = {
formData.append('file', file)
self.$emit('uploading')
self.uploading = true
self.uploadCount++
statusPosterService.uploadMedia({ store, formData })
.then((fileData) => {
self.$emit('uploaded', fileData)
self.uploading = false
self.decreaseUploadCount()
}, (error) => { // eslint-disable-line handle-callback-err
self.$emit('upload-failed', 'default')
self.uploading = false
self.decreaseUploadCount()
})
},
fileDrop (e) {
if (e.dataTransfer.files.length > 0) {
e.preventDefault() // allow dropping text like before
this.uploadFile(e.dataTransfer.files[0])
}
},
fileDrag (e) {
let types = e.dataTransfer.types
if (types.contains('Files')) {
e.dataTransfer.dropEffect = 'copy'
} else {
e.dataTransfer.dropEffect = 'none'
decreaseUploadCount () {
this.uploadCount--
if (this.uploadCount === 0) {
this.$emit('all-uploaded')
}
},
clearFile () {
@ -54,11 +51,13 @@ const mediaUpload = {
this.uploadReady = true
})
},
change ({ target }) {
for (var i = 0; i < target.files.length; i++) {
let file = target.files[i]
multiUpload (files) {
for (const file of files) {
this.uploadFile(file)
}
},
change ({ target }) {
this.multiUpload(target.files)
}
},
props: [
@ -67,7 +66,7 @@ const mediaUpload = {
watch: {
'dropFiles': function (fileInfos) {
if (!this.uploading) {
this.uploadFile(fileInfos[0])
this.multiUpload(fileInfos)
}
}
}

View file

@ -1,10 +1,5 @@
<template>
<div
class="media-upload"
@drop.prevent
@dragover.prevent="fileDrag"
@drop="fileDrop"
>
<div class="media-upload">
<label
class="label"
:title="$t('tool_tip.media_upload')"

View file

@ -1,8 +1,9 @@
<template>
<div
v-show="isOpen"
v-body-scroll-lock="isOpen"
v-body-scroll-lock="isOpen && !noBackground"
class="modal-view"
:class="classes"
@click.self="$emit('backdropClicked')"
>
<slot />
@ -15,6 +16,18 @@ export default {
isOpen: {
type: Boolean,
default: true
},
noBackground: {
type: Boolean,
default: false
}
},
computed: {
classes () {
return {
'modal-background': !this.noBackground,
'open': this.isOpen
}
}
}
}
@ -32,12 +45,22 @@ export default {
justify-content: center;
align-items: center;
overflow: auto;
pointer-events: none;
animation-duration: 0.2s;
background-color: rgba(0, 0, 0, 0.5);
animation-name: modal-background-fadein;
opacity: 0;
body:not(.scroll-locked) & {
opacity: 0;
> * {
pointer-events: initial;
}
&.modal-background {
pointer-events: initial;
background-color: rgba(0, 0, 0, 0.5);
}
&.open {
opacity: 1;
}
}

View file

@ -11,8 +11,11 @@ const MuteCard = {
user () {
return this.$store.getters.findUser(this.userId)
},
relationship () {
return this.$store.getters.relationship(this.userId)
},
muted () {
return this.user.muted
return this.relationship.muting
}
},
components: {
@ -21,13 +24,13 @@ const MuteCard = {
methods: {
unmuteUser () {
this.progress = true
this.$store.dispatch('unmuteUser', this.user.id).then(() => {
this.$store.dispatch('unmuteUser', this.userId).then(() => {
this.progress = false
})
},
muteUser () {
this.progress = true
this.$store.dispatch('muteUser', this.user.id).then(() => {
this.$store.dispatch('muteUser', this.userId).then(() => {
this.progress = false
})
}

View file

@ -1,7 +1,9 @@
import StatusContent from '../status_content/status_content.vue'
import Status from '../status/status.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import UserCard from '../user_card/user_card.vue'
import Timeago from '../timeago/timeago.vue'
import { isStatusNotification } from '../../services/notification_utils/notification_utils.js'
import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
@ -15,10 +17,11 @@ const Notification = {
},
props: [ 'notification' ],
components: {
Status,
StatusContent,
UserAvatar,
UserCard,
Timeago
Timeago,
Status
},
methods: {
toggleUserExpanded () {
@ -32,6 +35,24 @@ const Notification = {
},
toggleMute () {
this.unmuted = !this.unmuted
},
approveUser () {
this.$store.state.api.backendInteractor.approveUser({ id: this.user.id })
this.$store.dispatch('removeFollowRequest', this.user)
this.$store.dispatch('markSingleNotificationAsSeen', { id: this.notification.id })
this.$store.dispatch('updateNotification', {
id: this.notification.id,
updater: notification => {
notification.type = 'follow'
}
})
},
denyUser () {
this.$store.state.api.backendInteractor.denyUser({ id: this.user.id })
.then(() => {
this.$store.dispatch('dismissNotificationLocal', { id: this.notification.id })
this.$store.dispatch('removeFollowRequest', this.user)
})
}
},
computed: {
@ -56,7 +77,10 @@ const Notification = {
return this.generateUserProfileLink(this.targetUser)
},
needMute () {
return this.user.muted
return this.$store.getters.relationship(this.user.id).muting
},
isStatusNotification () {
return isStatusNotification(this.notification.type)
}
}
}

View file

@ -40,14 +40,14 @@
<div class="notification-right">
<UserCard
v-if="userExpanded"
:user="getUser(notification)"
:user-id="getUser(notification).id"
:rounded="true"
:bordered="true"
/>
<span class="notification-details">
<div class="name-and-action">
<!-- eslint-disable vue/no-v-html -->
<span
<bdi
v-if="!!notification.from_profile.name_html"
class="username"
:title="'@'+notification.from_profile.screen_name"
@ -74,6 +74,10 @@
<i class="fa icon-user-plus lit" />
<small>{{ $t('notifications.followed_you') }}</small>
</span>
<span v-if="notification.type === 'follow_request'">
<i class="fa icon-user lit" />
<small>{{ $t('notifications.follow_request') }}</small>
</span>
<span v-if="notification.type === 'move'">
<i class="fa icon-arrow-curved lit" />
<small>{{ $t('notifications.migrated_to') }}</small>
@ -87,18 +91,7 @@
</span>
</div>
<div
v-if="notification.type === 'follow' || notification.type === 'move'"
class="timeago"
>
<span class="faint">
<Timeago
:time="notification.created_at"
:auto-update="240"
/>
</span>
</div>
<div
v-else
v-if="isStatusNotification"
class="timeago"
>
<router-link
@ -112,6 +105,17 @@
/>
</router-link>
</div>
<div
v-else
class="timeago"
>
<span class="faint">
<Timeago
:time="notification.created_at"
:auto-update="240"
/>
</span>
</div>
<a
v-if="needMute"
href="#"
@ -119,12 +123,30 @@
><i class="button-icon icon-eye-off" /></a>
</span>
<div
v-if="notification.type === 'follow'"
v-if="notification.type === 'follow' || notification.type === 'follow_request'"
class="follow-text"
>
<router-link :to="userProfileLink">
<router-link
:to="userProfileLink"
class="follow-name"
>
@{{ notification.from_profile.screen_name }}
</router-link>
<div
v-if="notification.type === 'follow_request'"
style="white-space: nowrap;"
>
<i
class="icon-ok button-icon follow-request-accept"
:title="$t('tool_tip.accept_follow_request')"
@click="approveUser()"
/>
<i
class="icon-cancel button-icon follow-request-reject"
:title="$t('tool_tip.reject_follow_request')"
@click="denyUser()"
/>
</div>
</div>
<div
v-else-if="notification.type === 'move'"
@ -135,11 +157,9 @@
</router-link>
</div>
<template v-else>
<status
<status-content
class="faint"
:compact="true"
:statusoid="notification.action"
:no-heading="true"
:status="notification.action"
/>
</template>
</div>

View file

@ -36,6 +36,8 @@
border-bottom: 1px solid;
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
word-wrap: break-word;
word-break: break-word;
&:hover .animated.avatar {
canvas {
@ -46,41 +48,62 @@
}
}
.muted {
padding: .25em .6em;
}
.non-mention {
display: flex;
flex: 1;
flex-wrap: nowrap;
padding: 0.6em;
min-width: 0;
.avatar-container {
width: 32px;
height: 32px;
}
.status-el {
.status {
padding: 0.25em 0;
color: $fallback--faint;
color: var(--faint, $fallback--faint);
a {
color: var(--faintLink);
}
.status-content a {
color: var(--postFaintLink);
}
.status-body {
color: $fallback--faint;
color: var(--faint, $fallback--faint);
a {
color: var(--faintLink);
}
padding: 0;
.media-body {
margin: 0;
.status-content a {
color: var(--postFaintLink);
}
}
}
.follow-request-accept {
cursor: pointer;
&:hover {
color: $fallback--text;
color: var(--text, $fallback--text);
}
}
.follow-request-reject {
cursor: pointer;
&:hover {
color: $fallback--cRed;
color: var(--cRed, $fallback--cRed);
}
}
.follow-text, .move-text {
padding: 0.5em 0;
overflow-wrap: break-word;
display: flex;
justify-content: space-between;
.follow-name {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.status-el {
@ -142,6 +165,11 @@
color: var(--cGreen, $fallback--cGreen);
}
.icon-user.lit {
color: $fallback--cBlue;
color: var(--cBlue, $fallback--cBlue);
}
.icon-user-plus.lit {
color: $fallback--cBlue;
color: var(--cBlue, $fallback--cBlue);

View file

@ -0,0 +1,29 @@
<template>
<div class="panel-loading">
<span class="loading-text">
<i class="icon-spin4 animate-spin" />
{{ $t('general.loading') }}
</span>
</div>
</template>
<style lang="scss">
@import 'src/_variables.scss';
.panel-loading {
display: flex;
height: 100%;
align-items: center;
justify-content: center;
font-size: 2em;
color: $fallback--text;
color: var(--text, $fallback--text);
.loading-text i {
font-size: 3em;
line-height: 0;
vertical-align: middle;
color: $fallback--text;
color: var(--text, $fallback--text);
}
}
</style>

View file

@ -1,4 +1,3 @@
const Popover = {
name: 'Popover',
props: {
@ -10,6 +9,9 @@ const Popover = {
// 'container' for using offsetParent as boundaries for either axis
// or 'viewport'
boundTo: Object,
// Takes a selector to use as a replacement for the parent container
// for getting boundaries for x an y axis
boundToSelector: String,
// Takes a top/bottom/left/right object, how much space to leave
// between boundary and popover element
margin: Object,
@ -27,6 +29,10 @@ const Popover = {
}
},
methods: {
containerBoundingClientRect () {
const container = this.boundToSelector ? this.$el.closest(this.boundToSelector) : this.$el.offsetParent
return container.getBoundingClientRect()
},
updateStyles () {
if (this.hidden) {
this.styles = {
@ -45,7 +51,8 @@ const Popover = {
// Minor optimization, don't call a slow reflow call if we don't have to
const parentBounds = this.boundTo &&
(this.boundTo.x === 'container' || this.boundTo.y === 'container') &&
this.$el.offsetParent.getBoundingClientRect()
this.containerBoundingClientRect()
const margin = this.margin || {}
// What are the screen bounds for the popover? Viewport vs container

View file

@ -82,7 +82,9 @@ const PostStatusForm = {
contentType
},
caret: 0,
pollFormVisible: false
pollFormVisible: false,
showDropIcon: 'hide',
dropStopTimeout: null
}
},
computed: {
@ -102,7 +104,7 @@ const PostStatusForm = {
...this.$store.state.instance.customEmoji
],
users: this.$store.state.users.users,
updateUsersList: (input) => this.$store.dispatch('searchUsers', input)
updateUsersList: (query) => this.$store.dispatch('searchUsers', { query })
})
},
emojiSuggestor () {
@ -218,7 +220,6 @@ const PostStatusForm = {
},
addMediaFile (fileInfo) {
this.newStatus.files.push(fileInfo)
this.enableSubmit()
},
removeMediaFile (fileInfo) {
let index = this.newStatus.files.indexOf(fileInfo)
@ -227,7 +228,6 @@ const PostStatusForm = {
uploadFailed (errString, templateArgs) {
templateArgs = templateArgs || {}
this.error = this.$t('upload.error.base') + ' ' + this.$t('upload.error.' + errString, templateArgs)
this.enableSubmit()
},
disableSubmit () {
this.submitDisabled = true
@ -250,13 +250,27 @@ const PostStatusForm = {
}
},
fileDrop (e) {
if (e.dataTransfer.files.length > 0) {
if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
e.preventDefault() // allow dropping text like before
this.dropFiles = e.dataTransfer.files
clearTimeout(this.dropStopTimeout)
this.showDropIcon = 'hide'
}
},
fileDragStop (e) {
// The false-setting is done with delay because just using leave-events
// directly caused unwanted flickering, this is not perfect either but
// much less noticable.
clearTimeout(this.dropStopTimeout)
this.showDropIcon = 'fade'
this.dropStopTimeout = setTimeout(() => (this.showDropIcon = 'hide'), 500)
},
fileDrag (e) {
e.dataTransfer.dropEffect = 'copy'
if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
clearTimeout(this.dropStopTimeout)
this.showDropIcon = 'show'
}
},
onEmojiInputInput (e) {
this.$nextTick(() => {

View file

@ -6,7 +6,15 @@
<form
autocomplete="off"
@submit.prevent="postStatus(newStatus)"
@dragover.prevent="fileDrag"
>
<div
v-show="showDropIcon !== 'hide'"
:style="{ animation: showDropIcon === 'show' ? 'fade-in 0.25s' : 'fade-out 0.5s' }"
class="drop-indicator icon-upload"
@dragleave="fileDragStop"
@drop.stop="fileDrop"
/>
<div class="form-group">
<i18n
v-if="!$store.state.users.currentUser.locked && newStatus.visibility == 'private'"
@ -73,6 +81,7 @@
v-model="newStatus.spoilerText"
type="text"
:placeholder="$t('post_status.content_warning')"
:disabled="posting"
class="form-post-subject"
>
</EmojiInput>
@ -96,9 +105,7 @@
:disabled="posting"
class="form-post-body"
@keydown.meta.enter="postStatus(newStatus)"
@keyup.ctrl.enter="postStatus(newStatus)"
@drop="fileDrop"
@dragover.prevent="fileDrag"
@keydown.ctrl.enter="postStatus(newStatus)"
@input="resize"
@compositionupdate="resize"
@paste="paste"
@ -172,6 +179,7 @@
@uploading="disableSubmit"
@uploaded="addMediaFile"
@upload-failed="uploadFailed"
@all-uploaded="enableSubmit"
/>
<div
class="emoji-icon"
@ -446,7 +454,8 @@
form {
display: flex;
flex-direction: column;
padding: 0.6em;
margin: 0.6em;
position: relative;
}
.form-group {
@ -504,5 +513,35 @@
cursor: pointer;
z-index: 4;
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 0.6; }
}
@keyframes fade-out {
from { opacity: 0.6; }
to { opacity: 0; }
}
.drop-indicator {
position: absolute;
z-index: 1;
width: 100%;
height: 100%;
font-size: 5em;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.6;
color: $fallback--text;
color: var(--text, $fallback--text);
background-color: $fallback--bg;
background-color: var(--bg, $fallback--bg);
border-radius: $fallback--tooltipRadius;
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
border: 2px dashed $fallback--text;
border: 2px dashed var(--text, $fallback--text);
}
}
</style>

View file

@ -2,7 +2,7 @@ import Popover from '../popover/popover.vue'
import { mapGetters } from 'vuex'
const ReactButton = {
props: ['status', 'loggedIn'],
props: ['status'],
data () {
return {
filterWord: ''
@ -24,7 +24,7 @@ const ReactButton = {
},
computed: {
commonEmojis () {
return ['❤️', '😠', '👀', '😂', '🔥']
return ['👍', '😠', '👀', '😂', '🔥']
},
emojis () {
if (this.filterWord !== '') {

View file

@ -37,7 +37,6 @@
</div>
</div>
<i
v-if="loggedIn"
slot="trigger"
class="icon-smile button-icon add-reaction-button"
:title="$t('tool_tip.add_reaction')"

View file

@ -1,5 +1,5 @@
import { validationMixin } from 'vuelidate'
import { required, sameAs } from 'vuelidate/lib/validators'
import { required, requiredIf, sameAs } from 'vuelidate/lib/validators'
import { mapActions, mapState } from 'vuex'
const registration = {
@ -14,15 +14,17 @@ const registration = {
},
captcha: {}
}),
validations: {
user: {
email: { required },
username: { required },
fullname: { required },
password: { required },
confirm: {
required,
sameAsPassword: sameAs('password')
validations () {
return {
user: {
email: { required: requiredIf(() => this.accountActivationRequired) },
username: { required },
fullname: { required },
password: { required },
confirm: {
required,
sameAsPassword: sameAs('password')
}
}
}
},
@ -43,7 +45,8 @@ const registration = {
signedIn: (state) => !!state.users.currentUser,
isPending: (state) => state.users.signUpPending,
serverValidationErrors: (state) => state.users.signUpErrors,
termsOfService: (state) => state.instance.tos
termsOfService: (state) => state.instance.tos,
accountActivationRequired: (state) => state.instance.accountActivationRequired
})
},
methods: {

View file

@ -187,6 +187,9 @@
class="form-control"
type="text"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
>
</template>
</div>

View file

@ -1,128 +0,0 @@
/* eslint-env browser */
import { filter, trim } from 'lodash'
import TabSwitcher from '../tab_switcher/tab_switcher.js'
import StyleSwitcher from '../style_switcher/style_switcher.vue'
import InterfaceLanguageSwitcher from '../interface_language_switcher/interface_language_switcher.vue'
import { extractCommit } from '../../services/version/version.service'
import { instanceDefaultProperties, defaultState as configDefaultState } from '../../modules/config.js'
import Checkbox from '../checkbox/checkbox.vue'
const pleromaFeCommitUrl = 'https://git.pleroma.social/pleroma/pleroma-fe/commit/'
const pleromaBeCommitUrl = 'https://git.pleroma.social/pleroma/pleroma/commit/'
const multiChoiceProperties = [
'postContentType',
'subjectLineBehavior'
]
const settings = {
data () {
const instance = this.$store.state.instance
return {
loopSilentAvailable:
// Firefox
Object.getOwnPropertyDescriptor(HTMLVideoElement.prototype, 'mozHasAudio') ||
// Chrome-likes
Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'webkitAudioDecodedByteCount') ||
// Future spec, still not supported in Nightly 63 as of 08/2018
Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'audioTracks'),
backendVersion: instance.backendVersion,
frontendVersion: instance.frontendVersion
}
},
components: {
TabSwitcher,
StyleSwitcher,
InterfaceLanguageSwitcher,
Checkbox
},
computed: {
user () {
return this.$store.state.users.currentUser
},
currentSaveStateNotice () {
return this.$store.state.interface.settings.currentSaveStateNotice
},
postFormats () {
return this.$store.state.instance.postFormats || []
},
instanceSpecificPanelPresent () { return this.$store.state.instance.showInstanceSpecificPanel },
frontendVersionLink () {
return pleromaFeCommitUrl + this.frontendVersion
},
backendVersionLink () {
return pleromaBeCommitUrl + extractCommit(this.backendVersion)
},
// Getting localized values for instance-default properties
...instanceDefaultProperties
.filter(key => multiChoiceProperties.includes(key))
.map(key => [
key + 'DefaultValue',
function () {
return this.$store.getters.instanceDefaultConfig[key]
}
])
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
...instanceDefaultProperties
.filter(key => !multiChoiceProperties.includes(key))
.map(key => [
key + 'LocalizedValue',
function () {
return this.$t('settings.values.' + this.$store.getters.instanceDefaultConfig[key])
}
])
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
// Generating computed values for vuex properties
...Object.keys(configDefaultState)
.map(key => [key, {
get () { return this.$store.getters.mergedConfig[key] },
set (value) {
this.$store.dispatch('setOption', { name: key, value })
}
}])
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
// Special cases (need to transform values or perform actions first)
muteWordsString: {
get () { return this.$store.getters.mergedConfig.muteWords.join('\n') },
set (value) {
this.$store.dispatch('setOption', {
name: 'muteWords',
value: filter(value.split('\n'), (word) => trim(word).length > 0)
})
}
},
useStreamingApi: {
get () { return this.$store.getters.mergedConfig.useStreamingApi },
set (value) {
const promise = value
? this.$store.dispatch('enableMastoSockets')
: this.$store.dispatch('disableMastoSockets')
promise.then(() => {
this.$store.dispatch('setOption', { name: 'useStreamingApi', value })
}).catch((e) => {
console.error('Failed starting MastoAPI Streaming socket', e)
this.$store.dispatch('disableMastoSockets')
this.$store.dispatch('setOption', { name: 'useStreamingApi', value: false })
})
}
}
},
// Updating nested properties
watch: {
notificationVisibility: {
handler (value) {
this.$store.dispatch('setOption', {
name: 'notificationVisibility',
value: this.$store.getters.mergedConfig.notificationVisibility
})
},
deep: true
}
}
}
export default settings

View file

@ -1,424 +0,0 @@
<template>
<div class="settings panel panel-default">
<div class="panel-heading">
<div class="title">
{{ $t('settings.settings') }}
</div>
<transition name="fade">
<template v-if="currentSaveStateNotice">
<div
v-if="currentSaveStateNotice.error"
class="alert error"
@click.prevent
>
{{ $t('settings.saving_err') }}
</div>
<div
v-if="!currentSaveStateNotice.error"
class="alert transparent"
@click.prevent
>
{{ $t('settings.saving_ok') }}
</div>
</template>
</transition>
</div>
<div class="panel-body">
<keep-alive>
<tab-switcher>
<div :label="$t('settings.general')">
<div class="setting-item">
<h2>{{ $t('settings.interface') }}</h2>
<ul class="setting-list">
<li>
<interface-language-switcher />
</li>
<li v-if="instanceSpecificPanelPresent">
<Checkbox v-model="hideISP">
{{ $t('settings.hide_isp') }}
</Checkbox>
</li>
</ul>
</div>
<div class="setting-item">
<h2>{{ $t('nav.timeline') }}</h2>
<ul class="setting-list">
<li>
<Checkbox v-model="hideMutedPosts">
{{ $t('settings.hide_muted_posts') }} {{ $t('settings.instance_default', { value: hideMutedPostsLocalizedValue }) }}
</Checkbox>
</li>
<li>
<Checkbox v-model="collapseMessageWithSubject">
{{ $t('settings.collapse_subject') }} {{ $t('settings.instance_default', { value: collapseMessageWithSubjectLocalizedValue }) }}
</Checkbox>
</li>
<li>
<Checkbox v-model="streaming">
{{ $t('settings.streaming') }}
</Checkbox>
<ul
class="setting-list suboptions"
:class="[{disabled: !streaming}]"
>
<li>
<Checkbox
v-model="pauseOnUnfocused"
:disabled="!streaming"
>
{{ $t('settings.pause_on_unfocused') }}
</Checkbox>
</li>
</ul>
</li>
<li>
<Checkbox v-model="useStreamingApi">
{{ $t('settings.useStreamingApi') }}
<br>
<small>
{{ $t('settings.useStreamingApiWarning') }}
</small>
</Checkbox>
</li>
<li>
<Checkbox v-model="autoLoad">
{{ $t('settings.autoload') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="hoverPreview">
{{ $t('settings.reply_link_preview') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="emojiReactionsOnTimeline">
{{ $t('settings.emoji_reactions_on_timeline') }}
</Checkbox>
</li>
</ul>
</div>
<div class="setting-item">
<h2>{{ $t('settings.composing') }}</h2>
<ul class="setting-list">
<li>
<Checkbox v-model="scopeCopy">
{{ $t('settings.scope_copy') }} {{ $t('settings.instance_default', { value: scopeCopyLocalizedValue }) }}
</Checkbox>
</li>
<li>
<Checkbox v-model="alwaysShowSubjectInput">
{{ $t('settings.subject_input_always_show') }} {{ $t('settings.instance_default', { value: alwaysShowSubjectInputLocalizedValue }) }}
</Checkbox>
</li>
<li>
<div>
{{ $t('settings.subject_line_behavior') }}
<label
for="subjectLineBehavior"
class="select"
>
<select
id="subjectLineBehavior"
v-model="subjectLineBehavior"
>
<option value="email">
{{ $t('settings.subject_line_email') }}
{{ subjectLineBehaviorDefaultValue == 'email' ? $t('settings.instance_default_simple') : '' }}
</option>
<option value="masto">
{{ $t('settings.subject_line_mastodon') }}
{{ subjectLineBehaviorDefaultValue == 'mastodon' ? $t('settings.instance_default_simple') : '' }}
</option>
<option value="noop">
{{ $t('settings.subject_line_noop') }}
{{ subjectLineBehaviorDefaultValue == 'noop' ? $t('settings.instance_default_simple') : '' }}
</option>
</select>
<i class="icon-down-open" />
</label>
</div>
</li>
<li v-if="postFormats.length > 0">
<div>
{{ $t('settings.post_status_content_type') }}
<label
for="postContentType"
class="select"
>
<select
id="postContentType"
v-model="postContentType"
>
<option
v-for="postFormat in postFormats"
:key="postFormat"
:value="postFormat"
>
{{ $t(`post_status.content_type["${postFormat}"]`) }}
{{ postContentTypeDefaultValue === postFormat ? $t('settings.instance_default_simple') : '' }}
</option>
</select>
<i class="icon-down-open" />
</label>
</div>
</li>
<li>
<Checkbox v-model="minimalScopesMode">
{{ $t('settings.minimal_scopes_mode') }} {{ $t('settings.instance_default', { value: minimalScopesModeLocalizedValue }) }}
</Checkbox>
</li>
<li>
<Checkbox v-model="autohideFloatingPostButton">
{{ $t('settings.autohide_floating_post_button') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="padEmoji">
{{ $t('settings.pad_emoji') }}
</Checkbox>
</li>
</ul>
</div>
<div class="setting-item">
<h2>{{ $t('settings.attachments') }}</h2>
<ul class="setting-list">
<li>
<Checkbox v-model="hideAttachments">
{{ $t('settings.hide_attachments_in_tl') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="hideAttachmentsInConv">
{{ $t('settings.hide_attachments_in_convo') }}
</Checkbox>
</li>
<li>
<label for="maxThumbnails">
{{ $t('settings.max_thumbnails') }}
</label>
<input
id="maxThumbnails"
v-model.number="maxThumbnails"
class="number-input"
type="number"
min="0"
step="1"
>
</li>
<li>
<Checkbox v-model="hideNsfw">
{{ $t('settings.nsfw_clickthrough') }}
</Checkbox>
</li>
<ul class="setting-list suboptions">
<li>
<Checkbox
v-model="preloadImage"
:disabled="!hideNsfw"
>
{{ $t('settings.preload_images') }}
</Checkbox>
</li>
<li>
<Checkbox
v-model="useOneClickNsfw"
:disabled="!hideNsfw"
>
{{ $t('settings.use_one_click_nsfw') }}
</Checkbox>
</li>
</ul>
<li>
<Checkbox v-model="stopGifs">
{{ $t('settings.stop_gifs') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="loopVideo">
{{ $t('settings.loop_video') }}
</Checkbox>
<ul
class="setting-list suboptions"
:class="[{disabled: !streaming}]"
>
<li>
<Checkbox
v-model="loopVideoSilentOnly"
:disabled="!loopVideo || !loopSilentAvailable"
>
{{ $t('settings.loop_video_silent_only') }}
</Checkbox>
<div
v-if="!loopSilentAvailable"
class="unavailable"
>
<i class="icon-globe" />! {{ $t('settings.limited_availability') }}
</div>
</li>
</ul>
</li>
<li>
<Checkbox v-model="playVideosInModal">
{{ $t('settings.play_videos_in_modal') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="useContainFit">
{{ $t('settings.use_contain_fit') }}
</Checkbox>
</li>
</ul>
</div>
<div class="setting-item">
<h2>{{ $t('settings.notifications') }}</h2>
<ul class="setting-list">
<li>
<Checkbox v-model="webPushNotifications">
{{ $t('settings.enable_web_push_notifications') }}
</Checkbox>
</li>
</ul>
</div>
<div class="setting-item">
<h2>{{ $t('settings.fun') }}</h2>
<ul class="setting-list">
<li>
<Checkbox v-model="greentext">
{{ $t('settings.greentext') }} {{ $t('settings.instance_default', { value: greentextLocalizedValue }) }}
</Checkbox>
</li>
</ul>
</div>
</div>
<div :label="$t('settings.theme')">
<div class="setting-item">
<style-switcher />
</div>
</div>
<div :label="$t('settings.filtering')">
<div class="setting-item">
<div class="select-multiple">
<span class="label">{{ $t('settings.notification_visibility') }}</span>
<ul class="option-list">
<li>
<Checkbox v-model="notificationVisibility.likes">
{{ $t('settings.notification_visibility_likes') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="notificationVisibility.repeats">
{{ $t('settings.notification_visibility_repeats') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="notificationVisibility.follows">
{{ $t('settings.notification_visibility_follows') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="notificationVisibility.mentions">
{{ $t('settings.notification_visibility_mentions') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="notificationVisibility.moves">
{{ $t('settings.notification_visibility_moves') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="notificationVisibility.emojiReactions">
{{ $t('settings.notification_visibility_emoji_reactions') }}
</Checkbox>
</li>
</ul>
</div>
<div>
{{ $t('settings.replies_in_timeline') }}
<label
for="replyVisibility"
class="select"
>
<select
id="replyVisibility"
v-model="replyVisibility"
>
<option
value="all"
selected
>{{ $t('settings.reply_visibility_all') }}</option>
<option value="following">{{ $t('settings.reply_visibility_following') }}</option>
<option value="self">{{ $t('settings.reply_visibility_self') }}</option>
</select>
<i class="icon-down-open" />
</label>
</div>
<div>
<Checkbox v-model="hidePostStats">
{{ $t('settings.hide_post_stats') }} {{ $t('settings.instance_default', { value: hidePostStatsLocalizedValue }) }}
</Checkbox>
</div>
<div>
<Checkbox v-model="hideUserStats">
{{ $t('settings.hide_user_stats') }} {{ $t('settings.instance_default', { value: hideUserStatsLocalizedValue }) }}
</Checkbox>
</div>
</div>
<div class="setting-item">
<div>
<p>{{ $t('settings.filtering_explanation') }}</p>
<textarea
id="muteWords"
v-model="muteWordsString"
/>
</div>
<div>
<Checkbox v-model="hideFilteredStatuses">
{{ $t('settings.hide_filtered_statuses') }} {{ $t('settings.instance_default', { value: hideFilteredStatusesLocalizedValue }) }}
</Checkbox>
</div>
</div>
</div>
<div :label="$t('settings.version.title')">
<div class="setting-item">
<ul class="setting-list">
<li>
<p>{{ $t('settings.version.backend_version') }}</p>
<ul class="option-list">
<li>
<a
:href="backendVersionLink"
target="_blank"
>{{ backendVersion }}</a>
</li>
</ul>
</li>
<li>
<p>{{ $t('settings.version.frontend_version') }}</p>
<ul class="option-list">
<li>
<a
:href="frontendVersionLink"
target="_blank"
>{{ frontendVersion }}</a>
</li>
</ul>
</li>
</ul>
</div>
</div>
</tab-switcher>
</keep-alive>
</div>
</div>
</template>
<script src="./settings.js">
</script>

View file

@ -0,0 +1,58 @@
import {
instanceDefaultProperties,
multiChoiceProperties,
defaultState as configDefaultState
} from 'src/modules/config.js'
const SharedComputedObject = () => ({
user () {
return this.$store.state.users.currentUser
},
// Getting localized values for instance-default properties
...instanceDefaultProperties
.filter(key => multiChoiceProperties.includes(key))
.map(key => [
key + 'DefaultValue',
function () {
return this.$store.getters.instanceDefaultConfig[key]
}
])
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
...instanceDefaultProperties
.filter(key => !multiChoiceProperties.includes(key))
.map(key => [
key + 'LocalizedValue',
function () {
return this.$t('settings.values.' + this.$store.getters.instanceDefaultConfig[key])
}
])
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
// Generating computed values for vuex properties
...Object.keys(configDefaultState)
.map(key => [key, {
get () { return this.$store.getters.mergedConfig[key] },
set (value) {
this.$store.dispatch('setOption', { name: key, value })
}
}])
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
// Special cases (need to transform values or perform actions first)
useStreamingApi: {
get () { return this.$store.getters.mergedConfig.useStreamingApi },
set (value) {
const promise = value
? this.$store.dispatch('enableMastoSockets')
: this.$store.dispatch('disableMastoSockets')
promise.then(() => {
this.$store.dispatch('setOption', { name: 'useStreamingApi', value })
}).catch((e) => {
console.error('Failed starting MastoAPI Streaming socket', e)
this.$store.dispatch('disableMastoSockets')
this.$store.dispatch('setOption', { name: 'useStreamingApi', value: false })
})
}
}
})
export default SharedComputedObject

View file

@ -0,0 +1,42 @@
import Modal from 'src/components/modal/modal.vue'
import PanelLoading from 'src/components/panel_loading/panel_loading.vue'
import AsyncComponentError from 'src/components/async_component_error/async_component_error.vue'
import getResettableAsyncComponent from 'src/services/resettable_async_component.js'
const SettingsModal = {
components: {
Modal,
SettingsModalContent: getResettableAsyncComponent(
() => import('./settings_modal_content.vue'),
{
loading: PanelLoading,
error: AsyncComponentError,
delay: 0
}
)
},
methods: {
closeModal () {
this.$store.dispatch('closeSettingsModal')
},
peekModal () {
this.$store.dispatch('togglePeekSettingsModal')
}
},
computed: {
currentSaveStateNotice () {
return this.$store.state.interface.settings.currentSaveStateNotice
},
modalActivated () {
return this.$store.state.interface.settingsModalState !== 'hidden'
},
modalOpenedOnce () {
return this.$store.state.interface.settingsModalLoaded
},
modalPeeked () {
return this.$store.state.interface.settingsModalState === 'minimized'
}
}
}
export default SettingsModal

View file

@ -0,0 +1,44 @@
@import 'src/_variables.scss';
.settings-modal {
overflow: hidden;
&.peek {
.settings-modal-panel {
/* Explanation:
* Modal is positioned vertically centered.
* 100vh - 100% = Distance between modal's top+bottom boundaries and screen
* (100vh - 100%) / 2 = Distance between bottom (or top) boundary and screen
* + 100% - we move modal completely off-screen, it's top boundary touches
* bottom of the screen
* - 50px - leaving tiny amount of space so that titlebar + tiny amount of modal is visible
*/
transform: translateY(calc(((100vh - 100%) / 2 + 100%) - 50px));
}
}
.settings-modal-panel {
overflow: hidden;
transition: transform;
transition-timing-function: ease-in-out;
transition-duration: 300ms;
width: 1000px;
max-width: 90vw;
height: 90vh;
@media all and (max-width: 800px) {
max-width: 100vw;
height: 100vh;
}
.panel-body {
height: 100%;
overflow-y: hidden;
.btn {
min-height: 28px;
min-width: 10em;
padding: 0 2em;
}
}
}
}

View file

@ -0,0 +1,54 @@
<template>
<Modal
:is-open="modalActivated"
class="settings-modal"
:class="{ peek: modalPeeked }"
:no-background="modalPeeked"
>
<div class="settings-modal-panel panel">
<div class="panel-heading">
<span class="title">
{{ $t('settings.settings') }}
</span>
<transition name="fade">
<template v-if="currentSaveStateNotice">
<div
v-if="currentSaveStateNotice.error"
class="alert error"
@click.prevent
>
{{ $t('settings.saving_err') }}
</div>
<div
v-if="!currentSaveStateNotice.error"
class="alert transparent"
@click.prevent
>
{{ $t('settings.saving_ok') }}
</div>
</template>
</transition>
<button
class="btn"
@click="peekModal"
>
{{ $t('general.peek') }}
</button>
<button
class="btn"
@click="closeModal"
>
{{ $t('general.close') }}
</button>
</div>
<div class="panel-body">
<SettingsModalContent v-if="modalOpenedOnce" />
</div>
</div>
</Modal>
</template>
<script src="./settings_modal.js"></script>
<style src="./settings_modal.scss" lang="scss"></style>

View file

@ -0,0 +1,34 @@
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.js'
import DataImportExportTab from './tabs/data_import_export_tab.vue'
import MutesAndBlocksTab from './tabs/mutes_and_blocks_tab.vue'
import NotificationsTab from './tabs/notifications_tab.vue'
import FilteringTab from './tabs/filtering_tab.vue'
import SecurityTab from './tabs/security_tab/security_tab.vue'
import ProfileTab from './tabs/profile_tab.vue'
import GeneralTab from './tabs/general_tab.vue'
import VersionTab from './tabs/version_tab.vue'
import ThemeTab from './tabs/theme_tab/theme_tab.vue'
const SettingsModalContent = {
components: {
TabSwitcher,
DataImportExportTab,
MutesAndBlocksTab,
NotificationsTab,
FilteringTab,
SecurityTab,
ProfileTab,
GeneralTab,
VersionTab,
ThemeTab
},
computed: {
isLoggedIn () {
return !!this.$store.state.users.currentUser
}
}
}
export default SettingsModalContent

View file

@ -0,0 +1,43 @@
@import 'src/_variables.scss';
.settings_tab-switcher {
height: 100%;
.setting-item {
border-bottom: 2px solid var(--fg, $fallback--fg);
margin: 1em 1em 1.4em;
padding-bottom: 1.4em;
> div {
margin-bottom: .5em;
&:last-child {
margin-bottom: 0;
}
}
&:last-child {
border-bottom: none;
padding-bottom: 0;
margin-bottom: 1em;
}
select {
min-width: 10em;
}
textarea {
width: 100%;
max-width: 100%;
height: 100px;
}
.unavailable,
.unavailable i {
color: var(--cRed, $fallback--cRed);
color: $fallback--cRed;
}
.number-input {
max-width: 6em;
}
}
}

View file

@ -0,0 +1,73 @@
<template>
<tab-switcher
ref="tabSwitcher"
class="settings_tab-switcher"
:side-tab-bar="true"
:scrollable-tabs="true"
>
<div
:label="$t('settings.general')"
icon="wrench"
>
<GeneralTab />
</div>
<div
v-if="isLoggedIn"
:label="$t('settings.profile_tab')"
icon="user"
>
<ProfileTab />
</div>
<div
v-if="isLoggedIn"
:label="$t('settings.security_tab')"
icon="lock"
>
<SecurityTab />
</div>
<div
:label="$t('settings.filtering')"
icon="filter"
>
<FilteringTab />
</div>
<div
:label="$t('settings.theme')"
icon="brush"
>
<ThemeTab />
</div>
<div
v-if="isLoggedIn"
:label="$t('settings.notifications')"
icon="bell-ringing-o"
>
<NotificationsTab />
</div>
<div
v-if="isLoggedIn"
:label="$t('settings.data_import_export_tab')"
icon="download"
>
<DataImportExportTab />
</div>
<div
v-if="isLoggedIn"
:label="$t('settings.mutes_and_blocks')"
:fullHeight="true"
icon="eye-off"
>
<MutesAndBlocksTab />
</div>
<div
:label="$t('settings.version.title')"
icon="info-circled"
>
<VersionTab />
</div>
</tab-switcher>
</template>
<script src="./settings_modal_content.js"></script>
<style src="./settings_modal_content.scss" lang="scss"></style>

View file

@ -0,0 +1,65 @@
import Importer from 'src/components/importer/importer.vue'
import Exporter from 'src/components/exporter/exporter.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
const DataImportExportTab = {
data () {
return {
activeTab: 'profile',
newDomainToMute: ''
}
},
created () {
this.$store.dispatch('fetchTokens')
},
components: {
Importer,
Exporter,
Checkbox
},
computed: {
user () {
return this.$store.state.users.currentUser
}
},
methods: {
getFollowsContent () {
return this.$store.state.api.backendInteractor.exportFriends({ id: this.$store.state.users.currentUser.id })
.then(this.generateExportableUsersContent)
},
getBlocksContent () {
return this.$store.state.api.backendInteractor.fetchBlocks()
.then(this.generateExportableUsersContent)
},
importFollows (file) {
return this.$store.state.api.backendInteractor.importFollows({ file })
.then((status) => {
if (!status) {
throw new Error('failed')
}
})
},
importBlocks (file) {
return this.$store.state.api.backendInteractor.importBlocks({ file })
.then((status) => {
if (!status) {
throw new Error('failed')
}
})
},
generateExportableUsersContent (users) {
// Get addresses
return users.map((user) => {
// check is it's a local user
if (user && user.is_local) {
// append the instance address
// eslint-disable-next-line no-undef
return user.screen_name + '@' + location.hostname
}
return user.screen_name
}).join('\n')
}
}
}
export default DataImportExportTab

View file

@ -0,0 +1,43 @@
<template>
<div
:label="$t('settings.data_import_export_tab')"
>
<div class="setting-item">
<h2>{{ $t('settings.follow_import') }}</h2>
<p>{{ $t('settings.import_followers_from_a_csv_file') }}</p>
<Importer
:submit-handler="importFollows"
:success-message="$t('settings.follows_imported')"
:error-message="$t('settings.follow_import_error')"
/>
</div>
<div class="setting-item">
<h2>{{ $t('settings.follow_export') }}</h2>
<Exporter
:get-content="getFollowsContent"
filename="friends.csv"
:export-button-label="$t('settings.follow_export_button')"
/>
</div>
<div class="setting-item">
<h2>{{ $t('settings.block_import') }}</h2>
<p>{{ $t('settings.import_blocks_from_a_csv_file') }}</p>
<Importer
:submit-handler="importBlocks"
:success-message="$t('settings.blocks_imported')"
:error-message="$t('settings.block_import_error')"
/>
</div>
<div class="setting-item">
<h2>{{ $t('settings.block_export') }}</h2>
<Exporter
:get-content="getBlocksContent"
filename="blocks.csv"
:export-button-label="$t('settings.block_export_button')"
/>
</div>
</div>
</template>
<script src="./data_import_export_tab.js"></script>
<!-- <style lang="scss" src="./profile.scss"></style> -->

View file

@ -0,0 +1,44 @@
import { filter, trim } from 'lodash'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
const FilteringTab = {
data () {
return {
muteWordsStringLocal: this.$store.getters.mergedConfig.muteWords.join('\n')
}
},
components: {
Checkbox
},
computed: {
...SharedComputedObject(),
muteWordsString: {
get () {
return this.muteWordsStringLocal
},
set (value) {
this.muteWordsStringLocal = value
this.$store.dispatch('setOption', {
name: 'muteWords',
value: filter(value.split('\n'), (word) => trim(word).length > 0)
})
}
}
},
// Updating nested properties
watch: {
notificationVisibility: {
handler (value) {
this.$store.dispatch('setOption', {
name: 'notificationVisibility',
value: this.$store.getters.mergedConfig.notificationVisibility
})
},
deep: true
}
}
}
export default FilteringTab

View file

@ -0,0 +1,86 @@
<template>
<div :label="$t('settings.filtering')">
<div class="setting-item">
<div class="select-multiple">
<span class="label">{{ $t('settings.notification_visibility') }}</span>
<ul class="option-list">
<li>
<Checkbox v-model="notificationVisibility.likes">
{{ $t('settings.notification_visibility_likes') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="notificationVisibility.repeats">
{{ $t('settings.notification_visibility_repeats') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="notificationVisibility.follows">
{{ $t('settings.notification_visibility_follows') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="notificationVisibility.mentions">
{{ $t('settings.notification_visibility_mentions') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="notificationVisibility.moves">
{{ $t('settings.notification_visibility_moves') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="notificationVisibility.emojiReactions">
{{ $t('settings.notification_visibility_emoji_reactions') }}
</Checkbox>
</li>
</ul>
</div>
<div>
{{ $t('settings.replies_in_timeline') }}
<label
for="replyVisibility"
class="select"
>
<select
id="replyVisibility"
v-model="replyVisibility"
>
<option
value="all"
selected
>{{ $t('settings.reply_visibility_all') }}</option>
<option value="following">{{ $t('settings.reply_visibility_following') }}</option>
<option value="self">{{ $t('settings.reply_visibility_self') }}</option>
</select>
<i class="icon-down-open" />
</label>
</div>
<div>
<Checkbox v-model="hidePostStats">
{{ $t('settings.hide_post_stats') }} {{ $t('settings.instance_default', { value: hidePostStatsLocalizedValue }) }}
</Checkbox>
</div>
<div>
<Checkbox v-model="hideUserStats">
{{ $t('settings.hide_user_stats') }} {{ $t('settings.instance_default', { value: hideUserStatsLocalizedValue }) }}
</Checkbox>
</div>
</div>
<div class="setting-item">
<div>
<p>{{ $t('settings.filtering_explanation') }}</p>
<textarea
id="muteWords"
v-model="muteWordsString"
/>
</div>
<div>
<Checkbox v-model="hideFilteredStatuses">
{{ $t('settings.hide_filtered_statuses') }} {{ $t('settings.instance_default', { value: hideFilteredStatusesLocalizedValue }) }}
</Checkbox>
</div>
</div>
</div>
</template>
<script src="./filtering_tab.js"></script>

View file

@ -0,0 +1,31 @@
import Checkbox from 'src/components/checkbox/checkbox.vue'
import InterfaceLanguageSwitcher from 'src/components/interface_language_switcher/interface_language_switcher.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
const GeneralTab = {
data () {
return {
loopSilentAvailable:
// Firefox
Object.getOwnPropertyDescriptor(HTMLVideoElement.prototype, 'mozHasAudio') ||
// Chrome-likes
Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'webkitAudioDecodedByteCount') ||
// Future spec, still not supported in Nightly 63 as of 08/2018
Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'audioTracks')
}
},
components: {
Checkbox,
InterfaceLanguageSwitcher
},
computed: {
postFormats () {
return this.$store.state.instance.postFormats || []
},
instanceSpecificPanelPresent () { return this.$store.state.instance.showInstanceSpecificPanel },
...SharedComputedObject()
}
}
export default GeneralTab

View file

@ -0,0 +1,272 @@
<template>
<div :label="$t('settings.general')">
<div class="setting-item">
<h2>{{ $t('settings.interface') }}</h2>
<ul class="setting-list">
<li>
<interface-language-switcher />
</li>
<li v-if="instanceSpecificPanelPresent">
<Checkbox v-model="hideISP">
{{ $t('settings.hide_isp') }}
</Checkbox>
</li>
</ul>
</div>
<div class="setting-item">
<h2>{{ $t('nav.timeline') }}</h2>
<ul class="setting-list">
<li>
<Checkbox v-model="hideMutedPosts">
{{ $t('settings.hide_muted_posts') }} {{ $t('settings.instance_default', { value: hideMutedPostsLocalizedValue }) }}
</Checkbox>
</li>
<li>
<Checkbox v-model="collapseMessageWithSubject">
{{ $t('settings.collapse_subject') }} {{ $t('settings.instance_default', { value: collapseMessageWithSubjectLocalizedValue }) }}
</Checkbox>
</li>
<li>
<Checkbox v-model="streaming">
{{ $t('settings.streaming') }}
</Checkbox>
<ul
class="setting-list suboptions"
:class="[{disabled: !streaming}]"
>
<li>
<Checkbox
v-model="pauseOnUnfocused"
:disabled="!streaming"
>
{{ $t('settings.pause_on_unfocused') }}
</Checkbox>
</li>
</ul>
</li>
<li>
<Checkbox v-model="useStreamingApi">
{{ $t('settings.useStreamingApi') }}
<br>
<small>
{{ $t('settings.useStreamingApiWarning') }}
</small>
</Checkbox>
</li>
<li>
<Checkbox v-model="autoLoad">
{{ $t('settings.autoload') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="hoverPreview">
{{ $t('settings.reply_link_preview') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="emojiReactionsOnTimeline">
{{ $t('settings.emoji_reactions_on_timeline') }}
</Checkbox>
</li>
</ul>
</div>
<div class="setting-item">
<h2>{{ $t('settings.composing') }}</h2>
<ul class="setting-list">
<li>
<Checkbox v-model="scopeCopy">
{{ $t('settings.scope_copy') }} {{ $t('settings.instance_default', { value: scopeCopyLocalizedValue }) }}
</Checkbox>
</li>
<li>
<Checkbox v-model="alwaysShowSubjectInput">
{{ $t('settings.subject_input_always_show') }} {{ $t('settings.instance_default', { value: alwaysShowSubjectInputLocalizedValue }) }}
</Checkbox>
</li>
<li>
<div>
{{ $t('settings.subject_line_behavior') }}
<label
for="subjectLineBehavior"
class="select"
>
<select
id="subjectLineBehavior"
v-model="subjectLineBehavior"
>
<option value="email">
{{ $t('settings.subject_line_email') }}
{{ subjectLineBehaviorDefaultValue == 'email' ? $t('settings.instance_default_simple') : '' }}
</option>
<option value="masto">
{{ $t('settings.subject_line_mastodon') }}
{{ subjectLineBehaviorDefaultValue == 'mastodon' ? $t('settings.instance_default_simple') : '' }}
</option>
<option value="noop">
{{ $t('settings.subject_line_noop') }}
{{ subjectLineBehaviorDefaultValue == 'noop' ? $t('settings.instance_default_simple') : '' }}
</option>
</select>
<i class="icon-down-open" />
</label>
</div>
</li>
<li v-if="postFormats.length > 0">
<div>
{{ $t('settings.post_status_content_type') }}
<label
for="postContentType"
class="select"
>
<select
id="postContentType"
v-model="postContentType"
>
<option
v-for="postFormat in postFormats"
:key="postFormat"
:value="postFormat"
>
{{ $t(`post_status.content_type["${postFormat}"]`) }}
{{ postContentTypeDefaultValue === postFormat ? $t('settings.instance_default_simple') : '' }}
</option>
</select>
<i class="icon-down-open" />
</label>
</div>
</li>
<li>
<Checkbox v-model="minimalScopesMode">
{{ $t('settings.minimal_scopes_mode') }} {{ $t('settings.instance_default', { value: minimalScopesModeLocalizedValue }) }}
</Checkbox>
</li>
<li>
<Checkbox v-model="autohideFloatingPostButton">
{{ $t('settings.autohide_floating_post_button') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="padEmoji">
{{ $t('settings.pad_emoji') }}
</Checkbox>
</li>
</ul>
</div>
<div class="setting-item">
<h2>{{ $t('settings.attachments') }}</h2>
<ul class="setting-list">
<li>
<Checkbox v-model="hideAttachments">
{{ $t('settings.hide_attachments_in_tl') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="hideAttachmentsInConv">
{{ $t('settings.hide_attachments_in_convo') }}
</Checkbox>
</li>
<li>
<label for="maxThumbnails">
{{ $t('settings.max_thumbnails') }}
</label>
<input
id="maxThumbnails"
v-model.number="maxThumbnails"
class="number-input"
type="number"
min="0"
step="1"
>
</li>
<li>
<Checkbox v-model="hideNsfw">
{{ $t('settings.nsfw_clickthrough') }}
</Checkbox>
</li>
<ul class="setting-list suboptions">
<li>
<Checkbox
v-model="preloadImage"
:disabled="!hideNsfw"
>
{{ $t('settings.preload_images') }}
</Checkbox>
</li>
<li>
<Checkbox
v-model="useOneClickNsfw"
:disabled="!hideNsfw"
>
{{ $t('settings.use_one_click_nsfw') }}
</Checkbox>
</li>
</ul>
<li>
<Checkbox v-model="stopGifs">
{{ $t('settings.stop_gifs') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="loopVideo">
{{ $t('settings.loop_video') }}
</Checkbox>
<ul
class="setting-list suboptions"
:class="[{disabled: !streaming}]"
>
<li>
<Checkbox
v-model="loopVideoSilentOnly"
:disabled="!loopVideo || !loopSilentAvailable"
>
{{ $t('settings.loop_video_silent_only') }}
</Checkbox>
<div
v-if="!loopSilentAvailable"
class="unavailable"
>
<i class="icon-globe" />! {{ $t('settings.limited_availability') }}
</div>
</li>
</ul>
</li>
<li>
<Checkbox v-model="playVideosInModal">
{{ $t('settings.play_videos_in_modal') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="useContainFit">
{{ $t('settings.use_contain_fit') }}
</Checkbox>
</li>
</ul>
</div>
<div class="setting-item">
<h2>{{ $t('settings.notifications') }}</h2>
<ul class="setting-list">
<li>
<Checkbox v-model="webPushNotifications">
{{ $t('settings.enable_web_push_notifications') }}
</Checkbox>
</li>
</ul>
</div>
<div class="setting-item">
<h2>{{ $t('settings.fun') }}</h2>
<ul class="setting-list">
<li>
<Checkbox v-model="greentext">
{{ $t('settings.greentext') }} {{ $t('settings.instance_default', { value: greentextLocalizedValue }) }}
</Checkbox>
</li>
</ul>
</div>
</div>
</template>
<script src="./general_tab.js"></script>

View file

@ -0,0 +1,136 @@
import get from 'lodash/get'
import map from 'lodash/map'
import reject from 'lodash/reject'
import Autosuggest from 'src/components/autosuggest/autosuggest.vue'
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.js'
import BlockCard from 'src/components/block_card/block_card.vue'
import MuteCard from 'src/components/mute_card/mute_card.vue'
import DomainMuteCard from 'src/components/domain_mute_card/domain_mute_card.vue'
import SelectableList from 'src/components/selectable_list/selectable_list.vue'
import ProgressButton from 'src/components/progress_button/progress_button.vue'
import withSubscription from 'src/components/../hocs/with_subscription/with_subscription'
import Checkbox from 'src/components/checkbox/checkbox.vue'
const BlockList = withSubscription({
fetch: (props, $store) => $store.dispatch('fetchBlocks'),
select: (props, $store) => get($store.state.users.currentUser, 'blockIds', []),
childPropName: 'items'
})(SelectableList)
const MuteList = withSubscription({
fetch: (props, $store) => $store.dispatch('fetchMutes'),
select: (props, $store) => get($store.state.users.currentUser, 'muteIds', []),
childPropName: 'items'
})(SelectableList)
const DomainMuteList = withSubscription({
fetch: (props, $store) => $store.dispatch('fetchDomainMutes'),
select: (props, $store) => get($store.state.users.currentUser, 'domainMutes', []),
childPropName: 'items'
})(SelectableList)
const MutesAndBlocks = {
data () {
return {
activeTab: 'profile'
}
},
created () {
this.$store.dispatch('fetchTokens')
this.$store.dispatch('getKnownDomains')
},
components: {
TabSwitcher,
BlockList,
MuteList,
DomainMuteList,
BlockCard,
MuteCard,
DomainMuteCard,
ProgressButton,
Autosuggest,
Checkbox
},
computed: {
knownDomains () {
return this.$store.state.instance.knownDomains
},
user () {
return this.$store.state.users.currentUser
}
},
methods: {
importFollows (file) {
return this.$store.state.api.backendInteractor.importFollows({ file })
.then((status) => {
if (!status) {
throw new Error('failed')
}
})
},
importBlocks (file) {
return this.$store.state.api.backendInteractor.importBlocks({ file })
.then((status) => {
if (!status) {
throw new Error('failed')
}
})
},
generateExportableUsersContent (users) {
// Get addresses
return users.map((user) => {
// check is it's a local user
if (user && user.is_local) {
// append the instance address
// eslint-disable-next-line no-undef
return user.screen_name + '@' + location.hostname
}
return user.screen_name
}).join('\n')
},
activateTab (tabName) {
this.activeTab = tabName
},
filterUnblockedUsers (userIds) {
return reject(userIds, (userId) => {
const relationship = this.$store.getters.relationship(this.userId)
return relationship.blocking || userId === this.user.id
})
},
filterUnMutedUsers (userIds) {
return reject(userIds, (userId) => {
const relationship = this.$store.getters.relationship(this.userId)
return relationship.muting || userId === this.user.id
})
},
queryUserIds (query) {
return this.$store.dispatch('searchUsers', { query })
.then((users) => map(users, 'id'))
},
blockUsers (ids) {
return this.$store.dispatch('blockUsers', ids)
},
unblockUsers (ids) {
return this.$store.dispatch('unblockUsers', ids)
},
muteUsers (ids) {
return this.$store.dispatch('muteUsers', ids)
},
unmuteUsers (ids) {
return this.$store.dispatch('unmuteUsers', ids)
},
filterUnMutedDomains (urls) {
return urls.filter(url => !this.user.domainMutes.includes(url))
},
queryKnownDomains (query) {
return new Promise((resolve, reject) => {
resolve(this.knownDomains.filter(url => url.toLowerCase().includes(query)))
})
},
unmuteDomains (domains) {
return this.$store.dispatch('unmuteDomains', domains)
}
}
}
export default MutesAndBlocks

View file

@ -0,0 +1,29 @@
.mutes-and-blocks-tab {
height: 100%;
.usersearch-wrapper {
padding: 1em;
}
.bulk-actions {
text-align: right;
padding: 0 1em;
min-height: 28px;
}
.bulk-action-button {
width: 10em
}
.domain-mute-form {
padding: 1em;
display: flex;
flex-direction: column
}
.domain-mute-button {
align-self: flex-end;
margin-top: 1em;
width: 10em
}
}

View file

@ -0,0 +1,171 @@
<template>
<tab-switcher
:scrollable-tabs="true"
class="mutes-and-blocks-tab"
>
<div :label="$t('settings.blocks_tab')">
<div class="usersearch-wrapper">
<Autosuggest
:filter="filterUnblockedUsers"
:query="queryUserIds"
:placeholder="$t('settings.search_user_to_block')"
>
<BlockCard
slot-scope="row"
:user-id="row.item"
/>
</Autosuggest>
</div>
<BlockList
:refresh="true"
:get-key="i => i"
>
<template
slot="header"
slot-scope="{selected}"
>
<div class="bulk-actions">
<ProgressButton
v-if="selected.length > 0"
class="btn btn-default bulk-action-button"
:click="() => blockUsers(selected)"
>
{{ $t('user_card.block') }}
<template slot="progress">
{{ $t('user_card.block_progress') }}
</template>
</ProgressButton>
<ProgressButton
v-if="selected.length > 0"
class="btn btn-default"
:click="() => unblockUsers(selected)"
>
{{ $t('user_card.unblock') }}
<template slot="progress">
{{ $t('user_card.unblock_progress') }}
</template>
</ProgressButton>
</div>
</template>
<template
slot="item"
slot-scope="{item}"
>
<BlockCard :user-id="item" />
</template>
<template slot="empty">
{{ $t('settings.no_blocks') }}
</template>
</BlockList>
</div>
<div :label="$t('settings.mutes_tab')">
<tab-switcher>
<div label="Users">
<div class="usersearch-wrapper">
<Autosuggest
:filter="filterUnMutedUsers"
:query="queryUserIds"
:placeholder="$t('settings.search_user_to_mute')"
>
<MuteCard
slot-scope="row"
:user-id="row.item"
/>
</Autosuggest>
</div>
<MuteList
:refresh="true"
:get-key="i => i"
>
<template
slot="header"
slot-scope="{selected}"
>
<div class="bulk-actions">
<ProgressButton
v-if="selected.length > 0"
class="btn btn-default"
:click="() => muteUsers(selected)"
>
{{ $t('user_card.mute') }}
<template slot="progress">
{{ $t('user_card.mute_progress') }}
</template>
</ProgressButton>
<ProgressButton
v-if="selected.length > 0"
class="btn btn-default"
:click="() => unmuteUsers(selected)"
>
{{ $t('user_card.unmute') }}
<template slot="progress">
{{ $t('user_card.unmute_progress') }}
</template>
</ProgressButton>
</div>
</template>
<template
slot="item"
slot-scope="{item}"
>
<MuteCard :user-id="item" />
</template>
<template slot="empty">
{{ $t('settings.no_mutes') }}
</template>
</MuteList>
</div>
<div :label="$t('settings.domain_mutes')">
<div class="domain-mute-form">
<Autosuggest
:filter="filterUnMutedDomains"
:query="queryKnownDomains"
:placeholder="$t('settings.type_domains_to_mute')"
>
<DomainMuteCard
slot-scope="row"
:domain="row.item"
/>
</Autosuggest>
</div>
<DomainMuteList
:refresh="true"
:get-key="i => i"
>
<template
slot="header"
slot-scope="{selected}"
>
<div class="bulk-actions">
<ProgressButton
v-if="selected.length > 0"
class="btn btn-default"
:click="() => unmuteDomains(selected)"
>
{{ $t('domain_mute_card.unmute') }}
<template slot="progress">
{{ $t('domain_mute_card.unmute_progress') }}
</template>
</ProgressButton>
</div>
</template>
<template
slot="item"
slot-scope="{item}"
>
<DomainMuteCard :domain="item" />
</template>
<template slot="empty">
{{ $t('settings.no_mutes') }}
</template>
</DomainMuteList>
</div>
</tab-switcher>
</div>
</tab-switcher>
</template>
<script src="./mutes_and_blocks_tab.js"></script>
<style lang="scss" src="./mutes_and_blocks_tab.scss"></style>

View file

@ -0,0 +1,27 @@
import Checkbox from 'src/components/checkbox/checkbox.vue'
const NotificationsTab = {
data () {
return {
activeTab: 'profile',
notificationSettings: this.$store.state.users.currentUser.notification_settings,
newDomainToMute: ''
}
},
components: {
Checkbox
},
computed: {
user () {
return this.$store.state.users.currentUser
}
},
methods: {
updateNotificationSettings () {
this.$store.state.api.backendInteractor
.updateNotificationSettings({ settings: this.notificationSettings })
}
}
}
export default NotificationsTab

View file

@ -0,0 +1,54 @@
<template>
<div :label="$t('settings.notifications')">
<div class="setting-item">
<h2>{{ $t('settings.notification_setting_filters') }}</h2>
<div class="select-multiple">
<span class="label">{{ $t('settings.notification_setting') }}</span>
<ul class="option-list">
<li>
<Checkbox v-model="notificationSettings.follows">
{{ $t('settings.notification_setting_follows') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="notificationSettings.followers">
{{ $t('settings.notification_setting_followers') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="notificationSettings.non_follows">
{{ $t('settings.notification_setting_non_follows') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="notificationSettings.non_followers">
{{ $t('settings.notification_setting_non_followers') }}
</Checkbox>
</li>
</ul>
</div>
</div>
<div class="setting-item">
<h2>{{ $t('settings.notification_setting_privacy') }}</h2>
<p>
<Checkbox v-model="notificationSettings.privacy_option">
{{ $t('settings.notification_setting_privacy_option') }}
</Checkbox>
</p>
</div>
<div class="setting-item">
<p>{{ $t('settings.notification_mutes') }}</p>
<p>{{ $t('settings.notification_blocks') }}</p>
<button
class="btn btn-default"
@click="updateNotificationSettings"
>
{{ $t('general.submit') }}
</button>
</div>
</div>
</template>
<script src="./notifications_tab.js"></script>
<!-- <style lang="scss" src="./profile.scss"></style> -->

View file

@ -0,0 +1,179 @@
import unescape from 'lodash/unescape'
import ImageCropper from 'src/components/image_cropper/image_cropper.vue'
import ScopeSelector from 'src/components/scope_selector/scope_selector.vue'
import fileSizeFormatService from 'src/components/../services/file_size_format/file_size_format.js'
import ProgressButton from 'src/components/progress_button/progress_button.vue'
import EmojiInput from 'src/components/emoji_input/emoji_input.vue'
import suggestor from 'src/components/emoji_input/suggestor.js'
import Autosuggest from 'src/components/autosuggest/autosuggest.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
const ProfileTab = {
data () {
return {
newName: this.$store.state.users.currentUser.name,
newBio: unescape(this.$store.state.users.currentUser.description),
newLocked: this.$store.state.users.currentUser.locked,
newNoRichText: this.$store.state.users.currentUser.no_rich_text,
newDefaultScope: this.$store.state.users.currentUser.default_scope,
hideFollows: this.$store.state.users.currentUser.hide_follows,
hideFollowers: this.$store.state.users.currentUser.hide_followers,
hideFollowsCount: this.$store.state.users.currentUser.hide_follows_count,
hideFollowersCount: this.$store.state.users.currentUser.hide_followers_count,
showRole: this.$store.state.users.currentUser.show_role,
role: this.$store.state.users.currentUser.role,
discoverable: this.$store.state.users.currentUser.discoverable,
allowFollowingMove: this.$store.state.users.currentUser.allow_following_move,
pickAvatarBtnVisible: true,
bannerUploading: false,
backgroundUploading: false,
banner: null,
bannerPreview: null,
background: null,
backgroundPreview: null,
bannerUploadError: null,
backgroundUploadError: null
}
},
components: {
ScopeSelector,
ImageCropper,
EmojiInput,
Autosuggest,
ProgressButton,
Checkbox
},
computed: {
user () {
return this.$store.state.users.currentUser
},
emojiUserSuggestor () {
return suggestor({
emoji: [
...this.$store.state.instance.emoji,
...this.$store.state.instance.customEmoji
],
users: this.$store.state.users.users,
updateUsersList: (query) => this.$store.dispatch('searchUsers', { query })
})
},
emojiSuggestor () {
return suggestor({ emoji: [
...this.$store.state.instance.emoji,
...this.$store.state.instance.customEmoji
] })
}
},
methods: {
updateProfile () {
this.$store.state.api.backendInteractor
.updateProfile({
params: {
note: this.newBio,
locked: this.newLocked,
// Backend notation.
/* eslint-disable camelcase */
display_name: this.newName,
default_scope: this.newDefaultScope,
no_rich_text: this.newNoRichText,
hide_follows: this.hideFollows,
hide_followers: this.hideFollowers,
discoverable: this.discoverable,
allow_following_move: this.allowFollowingMove,
hide_follows_count: this.hideFollowsCount,
hide_followers_count: this.hideFollowersCount,
show_role: this.showRole
/* eslint-enable camelcase */
} }).then((user) => {
this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user)
})
},
changeVis (visibility) {
this.newDefaultScope = visibility
},
uploadFile (slot, e) {
const file = e.target.files[0]
if (!file) { return }
if (file.size > this.$store.state.instance[slot + 'limit']) {
const filesize = fileSizeFormatService.fileSizeFormat(file.size)
const allowedsize = fileSizeFormatService.fileSizeFormat(this.$store.state.instance[slot + 'limit'])
this[slot + 'UploadError'] = [
this.$t('upload.error.base'),
this.$t(
'upload.error.file_too_big',
{
filesize: filesize.num,
filesizeunit: filesize.unit,
allowedsize: allowedsize.num,
allowedsizeunit: allowedsize.unit
}
)
].join(' ')
return
}
// eslint-disable-next-line no-undef
const reader = new FileReader()
reader.onload = ({ target }) => {
const img = target.result
this[slot + 'Preview'] = img
this[slot] = file
}
reader.readAsDataURL(file)
},
submitAvatar (cropper, file) {
const that = this
return new Promise((resolve, reject) => {
function updateAvatar (avatar) {
that.$store.state.api.backendInteractor.updateAvatar({ avatar })
.then((user) => {
that.$store.commit('addNewUsers', [user])
that.$store.commit('setCurrentUser', user)
resolve()
})
.catch((err) => {
reject(new Error(that.$t('upload.error.base') + ' ' + err.message))
})
}
if (cropper) {
cropper.getCroppedCanvas().toBlob(updateAvatar, file.type)
} else {
updateAvatar(file)
}
})
},
submitBanner () {
if (!this.bannerPreview) { return }
this.bannerUploading = true
this.$store.state.api.backendInteractor.updateBanner({ banner: this.banner })
.then((user) => {
this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user)
this.bannerPreview = null
})
.catch((err) => {
this.bannerUploadError = this.$t('upload.error.base') + ' ' + err.message
})
.then(() => { this.bannerUploading = false })
},
submitBg () {
if (!this.backgroundPreview) { return }
let background = this.background
this.backgroundUploading = true
this.$store.state.api.backendInteractor.updateBg({ background }).then((data) => {
if (!data.error) {
this.$store.commit('addNewUsers', [data])
this.$store.commit('setCurrentUser', data)
this.backgroundPreview = null
} else {
this.backgroundUploadError = this.$t('upload.error.base') + data.error
}
this.backgroundUploading = false
})
}
}
}
export default ProfileTab

View file

@ -0,0 +1,82 @@
@import '../../../_variables.scss';
.profile-tab {
.bio {
margin: 0;
}
.visibility-tray {
padding-top: 5px;
}
input[type=file] {
padding: 5px;
height: auto;
}
.banner {
max-width: 100%;
}
.uploading {
font-size: 1.5em;
margin: 0.25em;
}
.name-changer {
width: 100%;
}
.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;
}
}
&-usersearch-wrapper {
padding: 1em;
}
&-bulk-actions {
text-align: right;
padding: 0 1em;
min-height: 28px;
button {
width: 10em;
}
}
&-domain-mute-form {
padding: 1em;
display: flex;
flex-direction: column;
button {
align-self: flex-end;
margin-top: 1em;
width: 10em;
}
}
.setting-subitem {
margin-left: 1.75em;
}
}

View file

@ -0,0 +1,213 @@
<template>
<div class="profile-tab">
<div class="setting-item">
<h2>{{ $t('settings.name_bio') }}</h2>
<p>{{ $t('settings.name') }}</p>
<EmojiInput
v-model="newName"
enable-emoji-picker
:suggest="emojiSuggestor"
>
<input
id="username"
v-model="newName"
classname="name-changer"
>
</EmojiInput>
<p>{{ $t('settings.bio') }}</p>
<EmojiInput
v-model="newBio"
enable-emoji-picker
:suggest="emojiUserSuggestor"
>
<textarea
v-model="newBio"
classname="bio"
/>
</EmojiInput>
<p>
<Checkbox v-model="newLocked">
{{ $t('settings.lock_account_description') }}
</Checkbox>
</p>
<div>
<label for="default-vis">{{ $t('settings.default_vis') }}</label>
<div
id="default-vis"
class="visibility-tray"
>
<scope-selector
:show-all="true"
:user-default="newDefaultScope"
:initial-scope="newDefaultScope"
:on-scope-change="changeVis"
/>
</div>
</div>
<p>
<Checkbox v-model="newNoRichText">
{{ $t('settings.no_rich_text_description') }}
</Checkbox>
</p>
<p>
<Checkbox v-model="hideFollows">
{{ $t('settings.hide_follows_description') }}
</Checkbox>
</p>
<p class="setting-subitem">
<Checkbox
v-model="hideFollowsCount"
:disabled="!hideFollows"
>
{{ $t('settings.hide_follows_count_description') }}
</Checkbox>
</p>
<p>
<Checkbox v-model="hideFollowers">
{{ $t('settings.hide_followers_description') }}
</Checkbox>
</p>
<p class="setting-subitem">
<Checkbox
v-model="hideFollowersCount"
:disabled="!hideFollowers"
>
{{ $t('settings.hide_followers_count_description') }}
</Checkbox>
</p>
<p>
<Checkbox v-model="allowFollowingMove">
{{ $t('settings.allow_following_move') }}
</Checkbox>
</p>
<p v-if="role === 'admin' || role === 'moderator'">
<Checkbox v-model="showRole">
<template v-if="role === 'admin'">
{{ $t('settings.show_admin_badge') }}
</template>
<template v-if="role === 'moderator'">
{{ $t('settings.show_moderator_badge') }}
</template>
</Checkbox>
</p>
<p>
<Checkbox v-model="discoverable">
{{ $t('settings.discoverable') }}
</Checkbox>
</p>
<button
:disabled="newName && newName.length === 0"
class="btn btn-default"
@click="updateProfile"
>
{{ $t('general.submit') }}
</button>
</div>
<div class="setting-item">
<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="current-avatar"
>
<p>{{ $t('settings.set_new_avatar') }}</p>
<button
v-show="pickAvatarBtnVisible"
id="pick-avatar"
class="btn"
type="button"
>
{{ $t('settings.upload_a_photo') }}
</button>
<image-cropper
trigger="#pick-avatar"
:submit-handler="submitAvatar"
@open="pickAvatarBtnVisible=false"
@close="pickAvatarBtnVisible=true"
/>
</div>
<div class="setting-item">
<h2>{{ $t('settings.profile_banner') }}</h2>
<p>{{ $t('settings.current_profile_banner') }}</p>
<img
:src="user.cover_photo"
class="banner"
>
<p>{{ $t('settings.set_new_profile_banner') }}</p>
<img
v-if="bannerPreview"
class="banner"
:src="bannerPreview"
>
<div>
<input
type="file"
@change="uploadFile('banner', $event)"
>
</div>
<i
v-if="bannerUploading"
class=" icon-spin4 animate-spin uploading"
/>
<button
v-else-if="bannerPreview"
class="btn btn-default"
@click="submitBanner"
>
{{ $t('general.submit') }}
</button>
<div
v-if="bannerUploadError"
class="alert error"
>
Error: {{ bannerUploadError }}
<i
class="button-icon icon-cancel"
@click="clearUploadError('banner')"
/>
</div>
</div>
<div class="setting-item">
<h2>{{ $t('settings.profile_background') }}</h2>
<p>{{ $t('settings.set_new_profile_background') }}</p>
<img
v-if="backgroundPreview"
class="bg"
:src="backgroundPreview"
>
<div>
<input
type="file"
@change="uploadFile('background', $event)"
>
</div>
<i
v-if="backgroundUploading"
class=" icon-spin4 animate-spin uploading"
/>
<button
v-else-if="backgroundPreview"
class="btn btn-default"
@click="submitBg"
>
{{ $t('general.submit') }}
</button>
<div
v-if="backgroundUploadError"
class="alert error"
>
Error: {{ backgroundUploadError }}
<i
class="button-icon icon-cancel"
@click="clearUploadError('background')"
/>
</div>
</div>
</div>
</template>
<script src="./profile_tab.js"></script>
<style lang="scss" src="./profile_tab.scss"></style>

View file

@ -137,20 +137,20 @@
<script src="./mfa.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.warning {
color: $fallback--cOrange;
color: var(--cOrange, $fallback--cOrange);
}
@import '../../../../_variables.scss';
.mfa-settings {
.mfa-heading, .method-item {
overflow: hidden;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: baseline;
}
.warning {
color: $fallback--cOrange;
color: var(--cOrange, $fallback--cOrange);
}
.setup-otp {
display: flex;
justify-content: center;

View file

@ -1,5 +1,5 @@
<template>
<div>
<div class="mfa-backup-codes">
<h4 v-if="displayTitle">
{{ $t('settings.mfa.recovery_codes') }}
</h4>
@ -21,13 +21,15 @@
</template>
<script src="./mfa_backup_codes.js"></script>
<style lang="scss">
@import '../../_variables.scss';
@import '../../../../_variables.scss';
.warning {
color: $fallback--cOrange;
color: var(--cOrange, $fallback--cOrange);
}
.backup-codes {
font-family: var(--postCodeFont, monospace);
.mfa-backup-codes {
.warning {
color: $fallback--cOrange;
color: var(--cOrange, $fallback--cOrange);
}
.backup-codes {
font-family: var(--postCodeFont, monospace);
}
}
</style>

View file

@ -0,0 +1,106 @@
import ProgressButton from 'src/components/progress_button/progress_button.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import Mfa from './mfa.vue'
const SecurityTab = {
data () {
return {
newEmail: '',
changeEmailError: false,
changeEmailPassword: '',
changedEmail: false,
deletingAccount: false,
deleteAccountConfirmPasswordInput: '',
deleteAccountError: false,
changePasswordInputs: [ '', '', '' ],
changedPassword: false,
changePasswordError: false
}
},
created () {
this.$store.dispatch('fetchTokens')
},
components: {
ProgressButton,
Mfa,
Checkbox
},
computed: {
user () {
return this.$store.state.users.currentUser
},
pleromaBackend () {
return this.$store.state.instance.pleromaBackend
},
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: {
confirmDelete () {
this.deletingAccount = true
},
deleteAccount () {
this.$store.state.api.backendInteractor.deleteAccount({ password: this.deleteAccountConfirmPasswordInput })
.then((res) => {
if (res.status === 'success') {
this.$store.dispatch('logout')
this.$router.push({ name: 'root' })
} else {
this.deleteAccountError = res.error
}
})
},
changePassword () {
const params = {
password: this.changePasswordInputs[0],
newPassword: this.changePasswordInputs[1],
newPasswordConfirmation: this.changePasswordInputs[2]
}
this.$store.state.api.backendInteractor.changePassword(params)
.then((res) => {
if (res.status === 'success') {
this.changedPassword = true
this.changePasswordError = false
this.logout()
} else {
this.changedPassword = false
this.changePasswordError = res.error
}
})
},
changeEmail () {
const params = {
email: this.newEmail,
password: this.changeEmailPassword
}
this.$store.state.api.backendInteractor.changeEmail(params)
.then((res) => {
if (res.status === 'success') {
this.changedEmail = true
this.changeEmailError = false
} else {
this.changedEmail = false
this.changeEmailError = res.error
}
})
},
logout () {
this.$store.dispatch('logout')
this.$router.replace('/')
},
revokeToken (id) {
if (window.confirm(`${this.$i18n.t('settings.revoke_token')}?`)) {
this.$store.dispatch('revokeToken', id)
}
}
}
}
export default SecurityTab

View file

@ -0,0 +1,143 @@
<template>
<div :label="$t('settings.security_tab')">
<div class="setting-item">
<h2>{{ $t('settings.change_email') }}</h2>
<div>
<p>{{ $t('settings.new_email') }}</p>
<input
v-model="newEmail"
type="email"
autocomplete="email"
>
</div>
<div>
<p>{{ $t('settings.current_password') }}</p>
<input
v-model="changeEmailPassword"
type="password"
autocomplete="current-password"
>
</div>
<button
class="btn btn-default"
@click="changeEmail"
>
{{ $t('general.submit') }}
</button>
<p v-if="changedEmail">
{{ $t('settings.changed_email') }}
</p>
<template v-if="changeEmailError !== false">
<p>{{ $t('settings.change_email_error') }}</p>
<p>{{ changeEmailError }}</p>
</template>
</div>
<div class="setting-item">
<h2>{{ $t('settings.change_password') }}</h2>
<div>
<p>{{ $t('settings.current_password') }}</p>
<input
v-model="changePasswordInputs[0]"
type="password"
>
</div>
<div>
<p>{{ $t('settings.new_password') }}</p>
<input
v-model="changePasswordInputs[1]"
type="password"
>
</div>
<div>
<p>{{ $t('settings.confirm_new_password') }}</p>
<input
v-model="changePasswordInputs[2]"
type="password"
>
</div>
<button
class="btn btn-default"
@click="changePassword"
>
{{ $t('general.submit') }}
</button>
<p v-if="changedPassword">
{{ $t('settings.changed_password') }}
</p>
<p v-else-if="changePasswordError !== false">
{{ $t('settings.change_password_error') }}
</p>
<p v-if="changePasswordError">
{{ changePasswordError }}
</p>
</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 />
</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>
<mfa />
<div class="setting-item">
<h2>{{ $t('settings.delete_account') }}</h2>
<p v-if="!deletingAccount">
{{ $t('settings.delete_account_description') }}
</p>
<div v-if="deletingAccount">
<p>{{ $t('settings.delete_account_instructions') }}</p>
<p>{{ $t('login.password') }}</p>
<input
v-model="deleteAccountConfirmPasswordInput"
type="password"
>
<button
class="btn btn-default"
@click="deleteAccount"
>
{{ $t('settings.delete_account') }}
</button>
</div>
<p v-if="deleteAccountError !== false">
{{ $t('settings.delete_account_error') }}
</p>
<p v-if="deleteAccountError">
{{ deleteAccountError }}
</p>
<button
v-if="!deletingAccount"
class="btn btn-default"
@click="confirmDelete"
>
{{ $t('general.submit') }}
</button>
</div>
</div>
</template>
<script src="./security_tab.js"></script>
<!-- <style lang="scss" src="./profile.scss"></style> -->

View file

@ -3,7 +3,7 @@ import {
rgb2hex,
hex2rgb,
getContrastRatioLayers
} from '../../services/color_convert/color_convert.js'
} from 'src/services/color_convert/color_convert.js'
import {
DEFAULT_SHADOWS,
generateColors,
@ -14,26 +14,27 @@ import {
getThemes,
shadows2to3,
colors2to3
} from '../../services/style_setter/style_setter.js'
} from 'src/services/style_setter/style_setter.js'
import {
SLOT_INHERITANCE
} from '../../services/theme_data/pleromafe.js'
} from 'src/services/theme_data/pleromafe.js'
import {
CURRENT_VERSION,
OPACITIES,
getLayers,
getOpacitySlot
} from '../../services/theme_data/theme_data.service.js'
import ColorInput from '../color_input/color_input.vue'
import RangeInput from '../range_input/range_input.vue'
import OpacityInput from '../opacity_input/opacity_input.vue'
import ShadowControl from '../shadow_control/shadow_control.vue'
import FontControl from '../font_control/font_control.vue'
import ContrastRatio from '../contrast_ratio/contrast_ratio.vue'
import TabSwitcher from '../tab_switcher/tab_switcher.js'
} from 'src/services/theme_data/theme_data.service.js'
import ColorInput from 'src/components/color_input/color_input.vue'
import RangeInput from 'src/components/range_input/range_input.vue'
import OpacityInput from 'src/components/opacity_input/opacity_input.vue'
import ShadowControl from 'src/components/shadow_control/shadow_control.vue'
import FontControl from 'src/components/font_control/font_control.vue'
import ContrastRatio from 'src/components/contrast_ratio/contrast_ratio.vue'
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.js'
import ExportImport from 'src/components/export_import/export_import.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import Preview from './preview.vue'
import ExportImport from '../export_import/export_import.vue'
import Checkbox from '../checkbox/checkbox.vue'
// List of color values used in v1
const v1OnlyNames = [

View file

@ -1,5 +1,6 @@
@import '../../_variables.scss';
.style-switcher {
@import 'src/_variables.scss';
.theme-tab {
padding-bottom: 2em;
.theme-warning {
display: flex;
align-items: baseline;
@ -54,10 +55,6 @@
}
}
.tab-switcher {
margin: 0 -1em;
}
.reset-container {
flex-wrap: wrap;
}
@ -98,20 +95,25 @@
align-items: baseline;
width: 100%;
min-height: 30px;
.btn {
min-width: 1px;
flex: 0 auto;
padding: 0 1em;
}
margin-bottom: 1em;
p {
flex: 1;
margin: 0;
margin-right: .5em;
}
}
margin-bottom: 1em;
.tab-header-buttons {
display: flex;
flex-direction: column;
.btn {
min-width: 1px;
flex: 0 auto;
padding: 0 1em;
margin-bottom: .5em;
}
}
.shadow-selector {
@ -161,7 +163,7 @@
border-bottom: 1px dashed;
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
margin: 1em -1em 0;
margin: 1em 0;
padding: 1em;
background: var(--body-background-image);
background-size: cover;
@ -328,6 +330,14 @@
padding: 20px;
}
.apply-container {
.btn {
min-height: 28px;
min-width: 10em;
padding: 0 2em;
}
}
.btn {
margin-left: .25em;
margin-right: .25em;

View file

@ -1,5 +1,5 @@
<template>
<div class="style-switcher">
<div class="theme-tab">
<div class="presets-container">
<div class="save-load">
<div
@ -126,18 +126,20 @@
>
<div class="tab-header">
<p>{{ $t('settings.theme_help') }}</p>
<button
class="btn"
@click="clearOpacity"
>
{{ $t('settings.style.switcher.clear_opacity') }}
</button>
<button
class="btn"
@click="clearV1"
>
{{ $t('settings.style.switcher.clear_all') }}
</button>
<div class="tab-header-buttons">
<button
class="btn"
@click="clearOpacity"
>
{{ $t('settings.style.switcher.clear_opacity') }}
</button>
<button
class="btn"
@click="clearV1"
>
{{ $t('settings.style.switcher.clear_all') }}
</button>
</div>
</div>
<p>{{ $t('settings.theme_help_v2_1') }}</p>
<h4>{{ $t('settings.style.common_colors.main') }}</h4>
@ -254,6 +256,13 @@
:label="$t('settings.links')"
/>
<ContrastRatio :contrast="previewContrast.postLink" />
<ColorInput
v-model="postGreentextColorLocal"
name="postGreentextColor"
:fallback="previewTheme.colors.cGreen"
:label="$t('settings.greentext')"
/>
<ContrastRatio :contrast="previewContrast.postGreentext" />
<h4>{{ $t('settings.style.advanced_colors.alert') }}</h4>
<ColorInput
v-model="alertErrorColorLocal"
@ -951,6 +960,6 @@
</div>
</template>
<script src="./style_switcher.js"></script>
<script src="./theme_tab.js"></script>
<style src="./style_switcher.scss" lang="scss"></style>
<style src="./theme_tab.scss" lang="scss"></style>

View file

@ -0,0 +1,24 @@
import { extractCommit } from 'src/services/version/version.service'
const pleromaFeCommitUrl = 'https://git.pleroma.social/pleroma/pleroma-fe/commit/'
const pleromaBeCommitUrl = 'https://git.pleroma.social/pleroma/pleroma/commit/'
const VersionTab = {
data () {
const instance = this.$store.state.instance
return {
backendVersion: instance.backendVersion,
frontendVersion: instance.frontendVersion
}
},
computed: {
frontendVersionLink () {
return pleromaFeCommitUrl + this.frontendVersion
},
backendVersionLink () {
return pleromaBeCommitUrl + extractCommit(this.backendVersion)
}
}
}
export default VersionTab

View file

@ -0,0 +1,31 @@
<template>
<div :label="$t('settings.version.title')">
<div class="setting-item">
<ul class="setting-list">
<li>
<p>{{ $t('settings.version.backend_version') }}</p>
<ul class="option-list">
<li>
<a
:href="backendVersionLink"
target="_blank"
>{{ backendVersion }}</a>
</li>
</ul>
</li>
<li>
<p>{{ $t('settings.version.frontend_version') }}</p>
<ul class="option-list">
<li>
<a
:href="frontendVersionLink"
target="_blank"
>{{ frontendVersion }}</a>
</li>
</ul>
</li>
</ul>
</div>
</div>
</template>
<script src="./version_tab.js">

View file

@ -62,6 +62,9 @@ const SideDrawer = {
},
touchMove (e) {
GestureService.updateSwipe(e, this.closeGesture)
},
openSettingsModal () {
this.$store.dispatch('openSettingsModal')
}
}
}

View file

@ -19,7 +19,7 @@
>
<UserCard
v-if="currentUser"
:user="currentUser"
:user-id="currentUser.id"
:hide-bio="true"
/>
<div
@ -122,9 +122,12 @@
</router-link>
</li>
<li @click="toggleDrawer">
<router-link :to="{ name: 'settings' }">
<a
href="#"
@click="openSettingsModal"
>
<i class="button-icon icon-cog" /> {{ $t("settings.settings") }}
</router-link>
</a>
</li>
<li @click="toggleDrawer">
<router-link :to="{ name: 'about'}">

View file

@ -1,24 +1,19 @@
import Attachment from '../attachment/attachment.vue'
import FavoriteButton from '../favorite_button/favorite_button.vue'
import ReactButton from '../react_button/react_button.vue'
import RetweetButton from '../retweet_button/retweet_button.vue'
import Poll from '../poll/poll.vue'
import ExtraButtons from '../extra_buttons/extra_buttons.vue'
import PostStatusForm from '../post_status_form/post_status_form.vue'
import UserCard from '../user_card/user_card.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import Gallery from '../gallery/gallery.vue'
import LinkPreview from '../link-preview/link-preview.vue'
import AvatarList from '../avatar_list/avatar_list.vue'
import Timeago from '../timeago/timeago.vue'
import StatusContent from '../status_content/status_content.vue'
import StatusPopover from '../status_popover/status_popover.vue'
import EmojiReactions from '../emoji_reactions/emoji_reactions.vue'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import fileType from 'src/services/file_type/file_type.service'
import { processHtml } from 'src/services/tiny_post_html_processor/tiny_post_html_processor.service.js'
import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
import { mentionMatchesUrl, extractTagFromUrl } from 'src/services/matcher/matcher.service.js'
import { filter, unescape, uniqBy } from 'lodash'
import { muteWordHits } from '../../services/status_parser/status_parser.js'
import { unescape, uniqBy } from 'lodash'
import { mapGetters, mapState } from 'vuex'
const Status = {
@ -43,20 +38,19 @@ const Status = {
replying: false,
unmuted: false,
userExpanded: false,
showingTall: this.inConversation && this.focused,
showingLongSubject: false,
error: null,
// not as computed because it sets the initial state which will be changed later
expandingSubject: !this.$store.getters.mergedConfig.collapseMessageWithSubject
error: null
}
},
computed: {
localCollapseSubjectDefault () {
return this.mergedConfig.collapseMessageWithSubject
},
muteWords () {
return this.mergedConfig.muteWords
},
showReasonMutedThread () {
return (
this.status.thread_muted ||
(this.status.reblog && this.status.reblog.thread_muted)
) && !this.inConversation
},
repeaterClass () {
const user = this.statusoid.user
return highlightClass(user)
@ -79,10 +73,6 @@ const Status = {
const highlight = this.mergedConfig.highlight
return highlightStyle(highlight[user.screen_name])
},
hideAttachments () {
return (this.mergedConfig.hideAttachments && !this.inConversation) ||
(this.mergedConfig.hideAttachmentsInConv && this.inConversation)
},
userProfileLink () {
return this.generateUserProfileLink(this.status.user.id, this.status.user.screen_name)
},
@ -110,15 +100,43 @@ const Status = {
return !!this.currentUser
},
muteWordHits () {
const statusText = this.status.text.toLowerCase()
const statusSummary = this.status.summary.toLowerCase()
const hits = filter(this.muteWords, (muteWord) => {
return statusText.includes(muteWord.toLowerCase()) || statusSummary.includes(muteWord.toLowerCase())
})
return hits
return muteWordHits(this.status, this.muteWords)
},
muted () {
const { status } = this
const { reblog } = status
const relationship = this.$store.getters.relationship(status.user.id)
const relationshipReblog = reblog && this.$store.getters.relationship(reblog.user.id)
const reasonsToMute = (
// Post is muted according to BE
status.muted ||
// Reprööt of a muted post according to BE
(reblog && reblog.muted) ||
// Muted user
relationship.muting ||
// Muted user of a reprööt
(relationshipReblog && relationshipReblog.muting) ||
// Thread is muted
status.thread_muted ||
// Wordfiltered
this.muteWordHits.length > 0
)
const excusesNotToMute = (
(
this.inProfile && (
// Don't mute user's posts on user timeline (except reblogs)
(!reblog && status.user.id === this.profileUserId) ||
// Same as above but also allow self-reblogs
(reblog && reblog.user.id === this.profileUserId)
)
) ||
// Don't mute statuses in muted conversation when said conversation is opened
(this.inConversation && status.thread_muted)
// No excuses if post has muted words
) && !this.muteWordHits.length > 0
return !this.unmuted && !excusesNotToMute && reasonsToMute
},
muted () { return !this.unmuted && ((!(this.inProfile && this.status.user.id === this.profileUserId) && this.status.user.muted) || (!this.inConversation && this.status.thread_muted) || this.muteWordHits.length > 0) },
hideFilteredStatuses () {
return this.mergedConfig.hideFilteredStatuses
},
@ -135,20 +153,6 @@ const Status = {
// use conversation highlight only when in conversation
return this.status.id === this.highlight
},
// This is a bit hacky, but we want to approximate post height before rendering
// so we count newlines (masto uses <p> for paragraphs, GS uses <br> between them)
// as well as approximate line count by counting characters and approximating ~80
// per line.
//
// Using max-height + overflow: auto for status components resulted in false positives
// very often with japanese characters, and it was very annoying.
tallStatus () {
const lengthScore = this.status.statusnet_html.split(/<p|<br/).length + this.status.text.length / 80
return lengthScore > 20
},
longSubject () {
return this.status.summary.length > 900
},
isReply () {
return !!(this.status.in_reply_to_status_id && this.status.in_reply_to_user_id)
},
@ -178,8 +182,11 @@ const Status = {
if (this.status.user.id === this.status.attentions[i].id) {
continue
}
const taggedUser = this.$store.getters.findUser(this.status.attentions[i].id)
if (checkFollowing && taggedUser && taggedUser.following) {
// There's zero guarantee of this working. If we happen to have that user and their
// relationship in store then it will work, but there's kinda little chance of having
// them for people you're not following.
const relationship = this.$store.state.users.relationships[this.status.attentions[i].id]
if (checkFollowing && relationship && relationship.following) {
return false
}
if (this.status.attentions[i].id === this.currentUser.id) {
@ -188,33 +195,6 @@ const Status = {
}
return this.status.attentions.length > 0
},
hideSubjectStatus () {
if (this.tallStatus && !this.localCollapseSubjectDefault) {
return false
}
return !this.expandingSubject && this.status.summary
},
hideTallStatus () {
if (this.status.summary && this.localCollapseSubjectDefault) {
return false
}
if (this.showingTall) {
return false
}
return this.tallStatus
},
showingMore () {
return (this.tallStatus && this.showingTall) || (this.status.summary && this.expandingSubject)
},
nsfwClickthrough () {
if (!this.status.nsfw) {
return false
}
if (this.status.summary && this.localCollapseSubjectDefault) {
return false
}
return true
},
replySubject () {
if (!this.status.summary) return ''
const decodedSummary = unescape(this.status.summary)
@ -228,83 +208,6 @@ const Status = {
return ''
}
},
attachmentSize () {
if ((this.mergedConfig.hideAttachments && !this.inConversation) ||
(this.mergedConfig.hideAttachmentsInConv && this.inConversation) ||
(this.status.attachments.length > this.maxThumbnails)) {
return 'hide'
} else if (this.compact) {
return 'small'
}
return 'normal'
},
galleryTypes () {
if (this.attachmentSize === 'hide') {
return []
}
return this.mergedConfig.playVideosInModal
? ['image', 'video']
: ['image']
},
galleryAttachments () {
return this.status.attachments.filter(
file => fileType.fileMatchesSomeType(this.galleryTypes, file)
)
},
nonGalleryAttachments () {
return this.status.attachments.filter(
file => !fileType.fileMatchesSomeType(this.galleryTypes, file)
)
},
hasImageAttachments () {
return this.status.attachments.some(
file => fileType.fileType(file.mimetype) === 'image'
)
},
hasVideoAttachments () {
return this.status.attachments.some(
file => fileType.fileType(file.mimetype) === 'video'
)
},
maxThumbnails () {
return this.mergedConfig.maxThumbnails
},
postBodyHtml () {
const html = this.status.statusnet_html
if (this.mergedConfig.greentext) {
try {
if (html.includes('&gt;')) {
// This checks if post has '>' at the beginning, excluding mentions so that @mention >impying works
return processHtml(html, (string) => {
if (string.includes('&gt;') &&
string
.replace(/<[^>]+?>/gi, '') // remove all tags
.replace(/@\w+/gi, '') // remove mentions (even failed ones)
.trim()
.startsWith('&gt;')) {
return `<span class='greentext'>${string}</span>`
} else {
return string
}
})
} else {
return html
}
} catch (e) {
console.err('Failed to process status html', e)
return html
}
} else {
return html
}
},
contentHtml () {
if (!this.status.summary_html) {
return this.postBodyHtml
}
return this.status.summary_html + '<br />' + this.postBodyHtml
},
combinedFavsAndRepeatsUsers () {
// Use the status from the global status repository since favs and repeats are saved in it
const combinedUsers = [].concat(
@ -313,9 +216,6 @@ const Status = {
)
return uniqBy(combinedUsers, 'id')
},
ownStatus () {
return this.status.user.id === this.currentUser.id
},
tags () {
return this.status.tags.filter(tagObj => tagObj.hasOwnProperty('name')).map(tagObj => tagObj.name).join(' ')
},
@ -329,21 +229,18 @@ const Status = {
})
},
components: {
Attachment,
FavoriteButton,
ReactButton,
RetweetButton,
ExtraButtons,
PostStatusForm,
Poll,
UserCard,
UserAvatar,
Gallery,
LinkPreview,
AvatarList,
Timeago,
StatusPopover,
EmojiReactions
EmojiReactions,
StatusContent
},
methods: {
visibilityIcon (visibility) {
@ -364,32 +261,6 @@ const Status = {
clearError () {
this.error = undefined
},
linkClicked (event) {
const target = event.target.closest('.status-content a')
if (target) {
if (target.className.match(/mention/)) {
const href = target.href
const attn = this.status.attentions.find(attn => mentionMatchesUrl(attn, href))
if (attn) {
event.stopPropagation()
event.preventDefault()
const link = this.generateUserProfileLink(attn.id, attn.screen_name)
this.$router.push(link)
return
}
}
if (target.rel.match(/(?:^|\s)tag(?:$|\s)/) || target.className.match(/hashtag/)) {
// Extract tag name from link url
const tag = extractTagFromUrl(target.href)
if (tag) {
const link = this.generateTagLink(tag)
this.$router.push(link)
return
}
}
window.open(target.href, '_blank')
}
},
toggleReplying () {
this.replying = !this.replying
},
@ -407,26 +278,8 @@ const Status = {
toggleUserExpanded () {
this.userExpanded = !this.userExpanded
},
toggleShowMore () {
if (this.showingTall) {
this.showingTall = false
} else if (this.expandingSubject && this.status.summary) {
this.expandingSubject = false
} else if (this.hideTallStatus) {
this.showingTall = true
} else if (this.hideSubjectStatus && this.status.summary) {
this.expandingSubject = true
}
},
generateUserProfileLink (id, name) {
return generateProfileLink(id, name, this.$store.state.instance.restrictedNicknames)
},
generateTagLink (tag) {
return `/tag/${tag}`
},
setMedia () {
const attachments = this.attachmentSize === 'hide' ? this.status.attachments : this.galleryAttachments
return () => this.$store.dispatch('setMedia', attachments)
}
},
watch: {

View file

@ -17,12 +17,33 @@
</div>
<template v-if="muted && !isPreview">
<div class="media status container muted">
<small>
<small class="username">
<i
v-if="muted && retweet"
class="button-icon icon-retweet"
/>
<router-link :to="userProfileLink">
{{ status.user.screen_name }}
</router-link>
</small>
<small class="muteWords">{{ muteWordHits.join(', ') }}</small>
<small
v-if="showReasonMutedThread"
class="mute-thread"
>
{{ $t('status.thread_muted') }}
</small>
<small
v-if="showReasonMutedThread && muteWordHits.length > 0"
class="mute-thread"
>
{{ $t('status.thread_muted_and_words') }}
</small>
<small
class="mute-words"
:title="muteWordHits.join(', ')"
>
{{ muteWordHits.join(', ') }}
</small>
<a
href="#"
class="unmute"
@ -94,7 +115,7 @@
<div class="status-body">
<UserCard
v-if="userExpanded"
:user="status.user"
:user-id="status.user.id"
:rounded="true"
:bordered="true"
class="status-usercard"
@ -241,118 +262,12 @@
</div>
</div>
<div
v-if="longSubject"
class="status-content-wrapper"
:class="{ 'tall-status': !showingLongSubject }"
>
<a
v-if="!showingLongSubject"
class="tall-status-hider"
:class="{ 'tall-status-hider_focused': isFocused }"
href="#"
@click.prevent="showingLongSubject=true"
>{{ $t("general.show_more") }}</a>
<div
class="status-content media-body"
@click.prevent="linkClicked"
v-html="contentHtml"
/>
<a
v-if="showingLongSubject"
href="#"
class="status-unhider"
@click.prevent="showingLongSubject=false"
>{{ $t("general.show_less") }}</a>
</div>
<div
v-else
:class="{'tall-status': hideTallStatus}"
class="status-content-wrapper"
>
<a
v-if="hideTallStatus"
class="tall-status-hider"
:class="{ 'tall-status-hider_focused': isFocused }"
href="#"
@click.prevent="toggleShowMore"
>{{ $t("general.show_more") }}</a>
<div
v-if="!hideSubjectStatus"
class="status-content media-body"
@click.prevent="linkClicked"
v-html="contentHtml"
/>
<div
v-else
class="status-content media-body"
@click.prevent="linkClicked"
v-html="status.summary_html"
/>
<a
v-if="hideSubjectStatus"
href="#"
class="cw-status-hider"
@click.prevent="toggleShowMore"
>
{{ $t("general.show_more") }}
<span
v-if="hasImageAttachments"
class="icon-picture"
/>
<span
v-if="hasVideoAttachments"
class="icon-video"
/>
<span
v-if="status.card"
class="icon-link"
/>
</a>
<a
v-if="showingMore"
href="#"
class="status-unhider"
@click.prevent="toggleShowMore"
>{{ $t("general.show_less") }}</a>
</div>
<div v-if="status.poll && status.poll.options">
<poll :base-poll="status.poll" />
</div>
<div
v-if="status.attachments && (!hideSubjectStatus || showingLongSubject)"
class="attachments media-body"
>
<attachment
v-for="attachment in nonGalleryAttachments"
:key="attachment.id"
class="non-gallery"
:size="attachmentSize"
:nsfw="nsfwClickthrough"
:attachment="attachment"
:allow-play="true"
:set-media="setMedia()"
/>
<gallery
v-if="galleryAttachments.length > 0"
:nsfw="nsfwClickthrough"
:attachments="galleryAttachments"
:set-media="setMedia()"
/>
</div>
<div
v-if="status.card && !hideSubjectStatus && !noHeading"
class="link-preview media-body"
>
<link-preview
:card="status.card"
:size="attachmentSize"
:nsfw="nsfwClickthrough"
/>
</div>
<StatusContent
:status="status"
:no-heading="noHeading"
:highlight="highlight"
:focused="isFocused"
/>
<transition name="fade">
<div
@ -419,7 +334,7 @@
:status="status"
/>
<ReactButton
:logged-in="loggedIn"
v-if="loggedIn"
:status="status"
/>
<extra-buttons
@ -645,105 +560,6 @@ $status-margin: 0.75em;
}
}
.tall-status {
position: relative;
height: 220px;
overflow-x: hidden;
overflow-y: hidden;
z-index: 1;
.status-content {
height: 100%;
mask: linear-gradient(to top, white, transparent) bottom/100% 70px no-repeat,
linear-gradient(to top, white, white);
/* Autoprefixed seem to ignore this one, and also syntax is different */
-webkit-mask-composite: xor;
mask-composite: exclude;
}
}
.tall-status-hider {
display: inline-block;
word-break: break-all;
position: absolute;
height: 70px;
margin-top: 150px;
width: 100%;
text-align: center;
line-height: 110px;
z-index: 2;
}
.status-unhider, .cw-status-hider {
width: 100%;
text-align: center;
display: inline-block;
word-break: break-all;
}
.status-content {
font-family: var(--postFont, sans-serif);
line-height: 1.4em;
white-space: pre-wrap;
a {
color: $fallback--link;
color: var(--postLink, $fallback--link);
}
img, video {
max-width: 100%;
max-height: 400px;
vertical-align: middle;
object-fit: contain;
&.emoji {
width: 32px;
height: 32px;
}
}
blockquote {
margin: 0.2em 0 0.2em 2em;
font-style: italic;
}
pre {
overflow: auto;
}
code, samp, kbd, var, pre {
font-family: var(--postCodeFont, monospace);
}
p {
margin: 0 0 1em 0;
}
p:last-child {
margin: 0 0 0 0;
}
h1 {
font-size: 1.1em;
line-height: 1.2em;
margin: 1.4em 0;
}
h2 {
font-size: 1.1em;
margin: 1.0em 0;
}
h3 {
font-size: 1em;
margin: 1.2em 0;
}
h4 {
margin: 1.1em 0;
}
}
.retweet-info {
padding: 0.4em $status-margin;
margin: 0;
@ -805,11 +621,6 @@ $status-margin: 0.75em;
}
}
.greentext {
color: $fallback--cGreen;
color: var(--cGreen, $fallback--cGreen);
}
.status-conversation {
border-left-style: solid;
}
@ -862,33 +673,54 @@ $status-margin: 0.75em;
}
.muted {
padding: 0.25em 0.5em;
button {
padding: .25em .6em;
height: 1.2em;
line-height: 1.2em;
text-overflow: ellipsis;
overflow: hidden;
display: flex;
flex-wrap: nowrap;
.username, .mute-thread, .mute-words {
word-wrap: normal;
word-break: normal;
white-space: nowrap;
}
.username, .mute-words {
text-overflow: ellipsis;
overflow: hidden;
}
.username {
flex: 0 1 auto;
margin-right: .2em;
}
.mute-thread {
flex: 0 0 auto;
}
.mute-words {
flex: 1 0 5em;
margin-left: .2em;
&::before {
content: ' '
}
}
.unmute {
flex: 0 0 auto;
margin-left: auto;
display: block;
margin-left: auto;
}
.muteWords {
margin-left: 10px;
}
}
a.unmute {
display: block;
margin-left: auto;
}
.reply-body {
flex: 1;
}
.timeline :not(.panel-disabled) > {
.status-el:last-child {
border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius;
border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius);
border-bottom: none;
}
}
.favs-repeated-users {
margin-top: $status-margin;

View file

@ -0,0 +1,210 @@
import Attachment from '../attachment/attachment.vue'
import Poll from '../poll/poll.vue'
import Gallery from '../gallery/gallery.vue'
import LinkPreview from '../link-preview/link-preview.vue'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import fileType from 'src/services/file_type/file_type.service'
import { processHtml } from 'src/services/tiny_post_html_processor/tiny_post_html_processor.service.js'
import { mentionMatchesUrl, extractTagFromUrl } from 'src/services/matcher/matcher.service.js'
import { mapGetters, mapState } from 'vuex'
const StatusContent = {
name: 'StatusContent',
props: [
'status',
'focused',
'noHeading',
'fullContent'
],
data () {
return {
showingTall: this.inConversation && this.focused,
showingLongSubject: false,
// not as computed because it sets the initial state which will be changed later
expandingSubject: !this.$store.getters.mergedConfig.collapseMessageWithSubject
}
},
computed: {
localCollapseSubjectDefault () {
return this.mergedConfig.collapseMessageWithSubject
},
hideAttachments () {
return (this.mergedConfig.hideAttachments && !this.inConversation) ||
(this.mergedConfig.hideAttachmentsInConv && this.inConversation)
},
// This is a bit hacky, but we want to approximate post height before rendering
// so we count newlines (masto uses <p> for paragraphs, GS uses <br> between them)
// as well as approximate line count by counting characters and approximating ~80
// per line.
//
// Using max-height + overflow: auto for status components resulted in false positives
// very often with japanese characters, and it was very annoying.
tallStatus () {
const lengthScore = this.status.statusnet_html.split(/<p|<br/).length + this.status.text.length / 80
return lengthScore > 20
},
longSubject () {
return this.status.summary.length > 900
},
// When a status has a subject and is also tall, we should only have one show more/less button. If the default is to collapse statuses with subjects, we just treat it like a status with a subject; otherwise, we just treat it like a tall status.
mightHideBecauseSubject () {
return this.status.summary && (!this.tallStatus || this.localCollapseSubjectDefault)
},
mightHideBecauseTall () {
return this.tallStatus && (!this.status.summary || !this.localCollapseSubjectDefault)
},
hideSubjectStatus () {
return this.mightHideBecauseSubject && !this.expandingSubject
},
hideTallStatus () {
return this.mightHideBecauseTall && !this.showingTall
},
showingMore () {
return (this.mightHideBecauseTall && this.showingTall) || (this.mightHideBecauseSubject && this.expandingSubject)
},
nsfwClickthrough () {
if (!this.status.nsfw) {
return false
}
if (this.status.summary && this.localCollapseSubjectDefault) {
return false
}
return true
},
attachmentSize () {
if ((this.mergedConfig.hideAttachments && !this.inConversation) ||
(this.mergedConfig.hideAttachmentsInConv && this.inConversation) ||
(this.status.attachments.length > this.maxThumbnails)) {
return 'hide'
} else if (this.compact) {
return 'small'
}
return 'normal'
},
galleryTypes () {
if (this.attachmentSize === 'hide') {
return []
}
return this.mergedConfig.playVideosInModal
? ['image', 'video']
: ['image']
},
galleryAttachments () {
return this.status.attachments.filter(
file => fileType.fileMatchesSomeType(this.galleryTypes, file)
)
},
nonGalleryAttachments () {
return this.status.attachments.filter(
file => !fileType.fileMatchesSomeType(this.galleryTypes, file)
)
},
hasImageAttachments () {
return this.status.attachments.some(
file => fileType.fileType(file.mimetype) === 'image'
)
},
hasVideoAttachments () {
return this.status.attachments.some(
file => fileType.fileType(file.mimetype) === 'video'
)
},
maxThumbnails () {
return this.mergedConfig.maxThumbnails
},
postBodyHtml () {
const html = this.status.statusnet_html
if (this.mergedConfig.greentext) {
try {
if (html.includes('&gt;')) {
// This checks if post has '>' at the beginning, excluding mentions so that @mention >impying works
return processHtml(html, (string) => {
if (string.includes('&gt;') &&
string
.replace(/<[^>]+?>/gi, '') // remove all tags
.replace(/@\w+/gi, '') // remove mentions (even failed ones)
.trim()
.startsWith('&gt;')) {
return `<span class='greentext'>${string}</span>`
} else {
return string
}
})
} else {
return html
}
} catch (e) {
console.err('Failed to process status html', e)
return html
}
} else {
return html
}
},
contentHtml () {
if (!this.status.summary_html) {
return this.postBodyHtml
}
return this.status.summary_html + '<br />' + this.postBodyHtml
},
...mapGetters(['mergedConfig']),
...mapState({
betterShadow: state => state.interface.browserSupport.cssFilter,
currentUser: state => state.users.currentUser
})
},
components: {
Attachment,
Poll,
Gallery,
LinkPreview
},
methods: {
linkClicked (event) {
const target = event.target.closest('.status-content a')
if (target) {
if (target.className.match(/mention/)) {
const href = target.href
const attn = this.status.attentions.find(attn => mentionMatchesUrl(attn, href))
if (attn) {
event.stopPropagation()
event.preventDefault()
const link = this.generateUserProfileLink(attn.id, attn.screen_name)
this.$router.push(link)
return
}
}
if (target.rel.match(/(?:^|\s)tag(?:$|\s)/) || target.className.match(/hashtag/)) {
// Extract tag name from dataset or link url
const tag = target.dataset.tag || extractTagFromUrl(target.href)
if (tag) {
const link = this.generateTagLink(tag)
this.$router.push(link)
return
}
}
window.open(target.href, '_blank')
}
},
toggleShowMore () {
if (this.mightHideBecauseTall) {
this.showingTall = !this.showingTall
} else if (this.mightHideBecauseSubject) {
this.expandingSubject = !this.expandingSubject
}
},
generateUserProfileLink (id, name) {
return generateProfileLink(id, name, this.$store.state.instance.restrictedNicknames)
},
generateTagLink (tag) {
return `/tag/${tag}`
},
setMedia () {
const attachments = this.attachmentSize === 'hide' ? this.status.attachments : this.galleryAttachments
return () => this.$store.dispatch('setMedia', attachments)
}
}
}
export default StatusContent

View file

@ -0,0 +1,240 @@
<template>
<!-- eslint-disable vue/no-v-html -->
<div class="status-body">
<slot name="header" />
<div
v-if="longSubject"
class="status-content-wrapper"
:class="{ 'tall-status': !showingLongSubject }"
>
<a
v-if="!showingLongSubject"
class="tall-status-hider"
:class="{ 'tall-status-hider_focused': focused }"
href="#"
@click.prevent="showingLongSubject=true"
>
{{ $t("general.show_more") }}
<span
v-if="hasImageAttachments"
class="icon-picture"
/>
<span
v-if="hasVideoAttachments"
class="icon-video"
/>
<span
v-if="status.card"
class="icon-link"
/>
</a>
<div
class="status-content media-body"
@click.prevent="linkClicked"
v-html="contentHtml"
/>
<a
v-if="showingLongSubject"
href="#"
class="status-unhider"
@click.prevent="showingLongSubject=false"
>{{ $t("general.show_less") }}</a>
</div>
<div
v-else
:class="{'tall-status': hideTallStatus}"
class="status-content-wrapper"
>
<a
v-if="hideTallStatus"
class="tall-status-hider"
:class="{ 'tall-status-hider_focused': focused }"
href="#"
@click.prevent="toggleShowMore"
>{{ $t("general.show_more") }}</a>
<div
v-if="!hideSubjectStatus"
class="status-content media-body"
@click.prevent="linkClicked"
v-html="contentHtml"
/>
<div
v-else
class="status-content media-body"
@click.prevent="linkClicked"
v-html="status.summary_html"
/>
<a
v-if="hideSubjectStatus"
href="#"
class="cw-status-hider"
@click.prevent="toggleShowMore"
>{{ $t("general.show_more") }}</a>
<a
v-if="showingMore"
href="#"
class="status-unhider"
@click.prevent="toggleShowMore"
>{{ $t("general.show_less") }}</a>
</div>
<div v-if="status.poll && status.poll.options">
<poll :base-poll="status.poll" />
</div>
<div
v-if="status.attachments.length !== 0 && (!hideSubjectStatus || showingLongSubject)"
class="attachments media-body"
>
<attachment
v-for="attachment in nonGalleryAttachments"
:key="attachment.id"
class="non-gallery"
:size="attachmentSize"
:nsfw="nsfwClickthrough"
:attachment="attachment"
:allow-play="true"
:set-media="setMedia()"
/>
<gallery
v-if="galleryAttachments.length > 0"
:nsfw="nsfwClickthrough"
:attachments="galleryAttachments"
:set-media="setMedia()"
/>
</div>
<div
v-if="status.card && !hideSubjectStatus && !noHeading"
class="link-preview media-body"
>
<link-preview
:card="status.card"
:size="attachmentSize"
:nsfw="nsfwClickthrough"
/>
</div>
<slot name="footer" />
</div>
<!-- eslint-enable vue/no-v-html -->
</template>
<script src="./status_content.js" ></script>
<style lang="scss">
@import '../../_variables.scss';
$status-margin: 0.75em;
.status-body {
flex: 1;
min-width: 0;
.tall-status {
position: relative;
height: 220px;
overflow-x: hidden;
overflow-y: hidden;
z-index: 1;
.status-content {
height: 100%;
mask: linear-gradient(to top, white, transparent) bottom/100% 70px no-repeat,
linear-gradient(to top, white, white);
/* Autoprefixed seem to ignore this one, and also syntax is different */
-webkit-mask-composite: xor;
mask-composite: exclude;
}
}
.tall-status-hider {
display: inline-block;
word-break: break-all;
position: absolute;
height: 70px;
margin-top: 150px;
width: 100%;
text-align: center;
line-height: 110px;
z-index: 2;
}
.status-unhider, .cw-status-hider {
width: 100%;
text-align: center;
display: inline-block;
word-break: break-all;
}
.status-content {
font-family: var(--postFont, sans-serif);
line-height: 1.4em;
white-space: pre-wrap;
img, video {
max-width: 100%;
max-height: 400px;
vertical-align: middle;
object-fit: contain;
&.emoji {
width: 32px;
height: 32px;
}
}
blockquote {
margin: 0.2em 0 0.2em 2em;
font-style: italic;
}
pre {
overflow: auto;
}
code, samp, kbd, var, pre {
font-family: var(--postCodeFont, monospace);
}
p {
margin: 0 0 1em 0;
}
p:last-child {
margin: 0 0 0 0;
}
h1 {
font-size: 1.1em;
line-height: 1.2em;
margin: 1.4em 0;
}
h2 {
font-size: 1.1em;
margin: 1.0em 0;
}
h3 {
font-size: 1em;
margin: 1.2em 0;
}
h4 {
margin: 1.1em 0;
}
}
}
.greentext {
color: $fallback--cGreen;
color: var(--postGreentext, $fallback--cGreen);
}
.timeline :not(.panel-disabled) > {
.status-el:last-child {
border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius;
border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius);
border-bottom: none;
}
}
</style>

View file

@ -5,6 +5,11 @@ const StatusPopover = {
props: [
'statusId'
],
data () {
return {
error: false
}
},
computed: {
status () {
return find(this.$store.state.statuses.allStatuses, { id: this.statusId })
@ -18,6 +23,8 @@ const StatusPopover = {
enter () {
if (!this.status) {
this.$store.dispatch('fetchStatus', this.statusId)
.then(data => (this.error = false))
.catch(e => (this.error = true))
}
}
}

View file

@ -17,9 +17,15 @@
:statusoid="status"
:compact="true"
/>
<div
v-else-if="error"
class="status-preview-no-content faint"
>
{{ $t('status.status_unavailable') }}
</div>
<div
v-else
class="status-preview-loading"
class="status-preview-no-content"
>
<i class="icon-spin4 animate-spin" />
</div>
@ -50,7 +56,7 @@
border: none;
}
.status-preview-loading {
.status-preview-no-content {
padding: 1em;
text-align: center;

View file

@ -23,12 +23,15 @@
<style lang="scss">
@import '../../_variables.scss';
.still-image {
position: relative;
line-height: 0;
overflow: hidden;
width: 100%;
height: 100%;
display: flex;
align-items: center;
&:hover canvas {
display: none;
@ -36,7 +39,7 @@
img {
width: 100%;
height: 100%;
min-height: 100%;
object-fit: contain;
}

View file

@ -24,6 +24,11 @@ export default Vue.component('tab-switcher', {
required: false,
type: Boolean,
default: false
},
sideTabBar: {
required: false,
type: Boolean,
default: false
}
},
data () {
@ -55,6 +60,9 @@ export default Vue.component('tab-switcher', {
this.onSwitch.call(null, this.$slots.default[index].key)
}
this.active = index
if (this.scrollableTabs) {
this.$refs.contents.scrollTop = 0
}
}
}
},
@ -64,7 +72,6 @@ export default Vue.component('tab-switcher', {
if (!slot.tag) return
const classesTab = ['tab']
const classesWrapper = ['tab-wrapper']
if (this.activeIndex === index) {
classesTab.push('active')
classesWrapper.push('active')
@ -87,8 +94,14 @@ export default Vue.component('tab-switcher', {
<button
disabled={slot.data.attrs.disabled}
onClick={this.activateTab(index)}
class={classesTab.join(' ')}>
{slot.data.attrs.label}</button>
class={classesTab.join(' ')}
type="button"
>
{!slot.data.attrs.icon ? '' : (<i class={'tab-icon icon-' + slot.data.attrs.icon}/>)}
<span class="text">
{slot.data.attrs.label}
</span>
</button>
</div>
)
})
@ -96,20 +109,32 @@ export default Vue.component('tab-switcher', {
const contents = this.$slots.default.map((slot, index) => {
if (!slot.tag) return
const active = this.activeIndex === index
if (this.renderOnlyFocused) {
return active
? <div class="active">{slot}</div>
: <div class="hidden"></div>
const classes = [ active ? 'active' : 'hidden' ]
if (slot.data.attrs.fullHeight) {
classes.push('full-height')
}
return <div class={active ? 'active' : 'hidden' }>{slot}</div>
const renderSlot = (!this.renderOnlyFocused || active)
? slot
: ''
return (
<div class={classes}>
{
this.sideTabBar
? <h1 class="mobile-label">{slot.data.attrs.label}</h1>
: ''
}
{renderSlot}
</div>
)
})
return (
<div class="tab-switcher">
<div class={'tab-switcher ' + (this.sideTabBar ? 'side-tabs' : 'top-tabs')}>
<div class="tabs">
{tabs}
</div>
<div class={'contents' + (this.scrollableTabs ? ' scrollable-tabs' : '')}>
<div ref="contents" class={'contents' + (this.scrollableTabs ? ' scrollable-tabs' : '')}>
{contents}
</div>
</div>

View file

@ -2,7 +2,144 @@
.tab-switcher {
display: flex;
flex-direction: column;
.tab-icon {
font-size: 2em;
display: block;
}
&.top-tabs {
flex-direction: column;
> .tabs {
width: 100%;
overflow-y: hidden;
overflow-x: auto;
padding-top: 5px;
flex-direction: row;
&::after, &::before {
content: '';
flex: 1 1 auto;
border-bottom: 1px solid;
border-bottom-color: $fallback--border;
border-bottom-color: var(--border, $fallback--border);
}
.tab-wrapper {
height: 28px;
&:not(.active)::after {
left: 0;
right: 0;
bottom: 0;
border-bottom: 1px solid;
border-bottom-color: $fallback--border;
border-bottom-color: var(--border, $fallback--border);
}
}
.tab {
width: 100%;
min-width: 1px;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
padding-bottom: 99px;
margin-bottom: 6px - 99px;
}
}
.contents.scrollable-tabs {
flex-basis: 0;
}
}
&.side-tabs {
flex-direction: row;
@media all and (max-width: 800px) {
overflow-x: auto;
}
> .contents {
flex: 1 1 auto;
}
> .tabs {
flex: 0 0 auto;
overflow-y: auto;
overflow-x: hidden;
flex-direction: column;
&::after, &::before {
flex-shrink: 0;
flex-basis: .5em;
content: '';
border-right: 1px solid;
border-right-color: $fallback--border;
border-right-color: var(--border, $fallback--border);
}
&::after {
flex-grow: 1;
}
&::before {
flex-grow: 0;
}
.tab-wrapper {
min-width: 10em;
display: flex;
flex-direction: column;
@media all and (max-width: 800px) {
min-width: 1em;
}
&:not(.active)::after {
top: 0;
right: 0;
bottom: 0;
border-right: 1px solid;
border-right-color: $fallback--border;
border-right-color: var(--border, $fallback--border);
}
&::before {
flex: 0 0 6px;
content: '';
border-right: 1px solid;
border-right-color: $fallback--border;
border-right-color: var(--border, $fallback--border);
}
&:last-child .tab {
margin-bottom: 0;
}
}
.tab {
flex: 1;
box-sizing: content-box;
min-width: 10em;
min-width: 1px;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
padding-left: 1em;
padding-right: calc(1em + 200px);
margin-right: -200px;
margin-left: 1em;
@media all and (max-width: 800px) {
padding-left: .25em;
padding-right: calc(.25em + 200px);
margin-right: calc(.25em - 200px);
margin-left: .25em;
.text {
display: none
}
}
}
}
}
.contents {
flex: 1 0 auto;
@ -11,88 +148,89 @@
.hidden {
display: none;
}
.full-height:not(.hidden) {
height: 100%;
display: flex;
flex-direction: column;
> *:not(.mobile-label) {
flex: 1;
}
}
&.scrollable-tabs {
flex-basis: 0;
overflow-y: auto;
}
}
.tab {
position: relative;
white-space: nowrap;
padding: 6px 1em;
background-color: $fallback--fg;
background-color: var(--tab, $fallback--fg);
&, &:active .tab-icon {
color: $fallback--text;
color: var(--tabText, $fallback--text);
}
&:not(.active) {
z-index: 4;
&:hover {
z-index: 6;
}
}
&.active {
background: transparent;
z-index: 5;
color: $fallback--text;
color: var(--tabActiveText, $fallback--text);
}
img {
max-height: 26px;
vertical-align: top;
margin-top: -5px;
}
}
.tabs {
display: flex;
position: relative;
width: 100%;
overflow-y: hidden;
overflow-x: auto;
padding-top: 5px;
box-sizing: border-box;
&::after, &::before {
display: block;
content: '';
flex: 1 1 auto;
border-bottom: 1px solid;
border-bottom-color: $fallback--border;
border-bottom-color: var(--border, $fallback--border);
}
}
.tab-wrapper {
height: 28px;
position: relative;
display: flex;
flex: 0 0 auto;
.tab-wrapper {
position: relative;
display: flex;
flex: 0 0 auto;
.tab {
width: 100%;
min-width: 1px;
position: relative;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
padding: 6px 1em;
padding-bottom: 99px;
margin-bottom: 6px - 99px;
white-space: nowrap;
color: $fallback--text;
color: var(--tabText, $fallback--text);
background-color: $fallback--fg;
background-color: var(--tab, $fallback--fg);
&:not(.active) {
z-index: 4;
&:hover {
z-index: 6;
}
}
&.active {
background: transparent;
z-index: 5;
color: $fallback--text;
color: var(--tabActiveText, $fallback--text);
}
img {
max-height: 26px;
vertical-align: top;
margin-top: -5px;
}
}
&:not(.active) {
&::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 7;
border-bottom: 1px solid;
border-bottom-color: $fallback--border;
border-bottom-color: var(--border, $fallback--border);
}
&:not(.active) {
&::after {
content: '';
position: absolute;
z-index: 7;
}
}
}
.mobile-label {
padding-left: .3em;
padding-bottom: .25em;
margin-top: .5em;
margin-left: .2em;
margin-bottom: .25em;
border-bottom: 1px solid var(--border, $fallback--border);
@media all and (min-width: 800px) {
display: none;
}
}
}

View file

@ -9,7 +9,7 @@ import { mapGetters } from 'vuex'
export default {
props: [
'user', 'switcher', 'selected', 'hideBio', 'rounded', 'bordered', 'allowZoomingAvatar'
'userId', 'switcher', 'selected', 'hideBio', 'rounded', 'bordered', 'allowZoomingAvatar'
],
data () {
return {
@ -21,6 +21,12 @@ export default {
this.$store.dispatch('fetchUserRelationship', this.user.id)
},
computed: {
user () {
return this.$store.getters.findUser(this.userId)
},
relationship () {
return this.$store.getters.relationship(this.userId)
},
classes () {
return [{
'user-card-rounded-t': this.rounded === 'top', // set border-top-left-radius and border-top-right-radius

View file

@ -50,15 +50,6 @@
>
{{ user.name }}
</div>
<router-link
v-if="!isOtherUser"
:to="{ name: 'user-settings' }"
>
<i
class="button-icon icon-wrench usersettings"
:title="$t('tool_tip.user_settings')"
/>
</router-link>
<a
v-if="isOtherUser && !user.is_local"
:href="user.statusnet_profile_url"
@ -69,6 +60,7 @@
<AccountActions
v-if="isOtherUser && loggedIn"
:user="user"
:relationship="relationship"
/>
</div>
<div class="bottom-line">
@ -92,7 +84,7 @@
</div>
<div class="user-meta">
<div
v-if="user.follows_you && loggedIn && isOtherUser"
v-if="relationship.followed_by && loggedIn && isOtherUser"
class="following"
>
{{ $t('user_card.follows_you') }}
@ -117,7 +109,7 @@
type="color"
>
<label
for="style-switcher"
for="theme_tab"
class="userHighlightSel select"
>
<select
@ -139,10 +131,10 @@
class="user-interactions"
>
<div class="btn-group">
<FollowButton :user="user" />
<template v-if="user.following">
<FollowButton :relationship="relationship" />
<template v-if="relationship.following">
<ProgressButton
v-if="!user.subscribed"
v-if="!relationship.subscribing"
class="btn btn-default"
:click="subscribeUser"
:title="$t('user_card.subscribe')"
@ -161,7 +153,7 @@
</div>
<div>
<button
v-if="user.muted"
v-if="relationship.muting"
class="btn btn-default btn-block toggled"
@click="unmuteUser"
>

View file

@ -6,7 +6,7 @@
class="panel panel-default signed-in"
>
<UserCard
:user="user"
:user-id="user.id"
:hide-bio="true"
rounded="top"
/>

View file

@ -3,6 +3,7 @@ import UserCard from '../user_card/user_card.vue'
import FollowCard from '../follow_card/follow_card.vue'
import Timeline from '../timeline/timeline.vue'
import Conversation from '../conversation/conversation.vue'
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.js'
import List from '../list/list.vue'
import withLoadMore from '../../hocs/with_load_more/with_load_more'
@ -146,6 +147,7 @@ const UserProfile = {
FollowerList,
FriendList,
FollowCard,
TabSwitcher,
Conversation
}
}

View file

@ -5,7 +5,7 @@
class="user-profile panel panel-default"
>
<UserCard
:user="user"
:user-id="userId"
:switcher="true"
:selected="timeline.viewing"
:allow-zooming-avatar="true"

View file

@ -1,393 +0,0 @@
import unescape from 'lodash/unescape'
import get from 'lodash/get'
import map from 'lodash/map'
import reject from 'lodash/reject'
import TabSwitcher from '../tab_switcher/tab_switcher.js'
import ImageCropper from '../image_cropper/image_cropper.vue'
import StyleSwitcher from '../style_switcher/style_switcher.vue'
import ScopeSelector from '../scope_selector/scope_selector.vue'
import fileSizeFormatService from '../../services/file_size_format/file_size_format.js'
import BlockCard from '../block_card/block_card.vue'
import MuteCard from '../mute_card/mute_card.vue'
import DomainMuteCard from '../domain_mute_card/domain_mute_card.vue'
import SelectableList from '../selectable_list/selectable_list.vue'
import ProgressButton from '../progress_button/progress_button.vue'
import EmojiInput from '../emoji_input/emoji_input.vue'
import suggestor from '../emoji_input/suggestor.js'
import Autosuggest from '../autosuggest/autosuggest.vue'
import Importer from '../importer/importer.vue'
import Exporter from '../exporter/exporter.vue'
import withSubscription from '../../hocs/with_subscription/with_subscription'
import Checkbox from '../checkbox/checkbox.vue'
import Mfa from './mfa.vue'
const BlockList = withSubscription({
fetch: (props, $store) => $store.dispatch('fetchBlocks'),
select: (props, $store) => get($store.state.users.currentUser, 'blockIds', []),
childPropName: 'items'
})(SelectableList)
const MuteList = withSubscription({
fetch: (props, $store) => $store.dispatch('fetchMutes'),
select: (props, $store) => get($store.state.users.currentUser, 'muteIds', []),
childPropName: 'items'
})(SelectableList)
const DomainMuteList = withSubscription({
fetch: (props, $store) => $store.dispatch('fetchDomainMutes'),
select: (props, $store) => get($store.state.users.currentUser, 'domainMutes', []),
childPropName: 'items'
})(SelectableList)
const UserSettings = {
data () {
return {
newEmail: '',
newName: this.$store.state.users.currentUser.name,
newBio: unescape(this.$store.state.users.currentUser.description),
newLocked: this.$store.state.users.currentUser.locked,
newNoRichText: this.$store.state.users.currentUser.no_rich_text,
newDefaultScope: this.$store.state.users.currentUser.default_scope,
hideFollows: this.$store.state.users.currentUser.hide_follows,
hideFollowers: this.$store.state.users.currentUser.hide_followers,
hideFollowsCount: this.$store.state.users.currentUser.hide_follows_count,
hideFollowersCount: this.$store.state.users.currentUser.hide_followers_count,
showRole: this.$store.state.users.currentUser.show_role,
role: this.$store.state.users.currentUser.role,
discoverable: this.$store.state.users.currentUser.discoverable,
allowFollowingMove: this.$store.state.users.currentUser.allow_following_move,
pickAvatarBtnVisible: true,
bannerUploading: false,
backgroundUploading: false,
banner: null,
bannerPreview: null,
background: null,
backgroundPreview: null,
bannerUploadError: null,
backgroundUploadError: null,
changeEmailError: false,
changeEmailPassword: '',
changedEmail: false,
deletingAccount: false,
deleteAccountConfirmPasswordInput: '',
deleteAccountError: false,
changePasswordInputs: [ '', '', '' ],
changedPassword: false,
changePasswordError: false,
activeTab: 'profile',
notificationSettings: this.$store.state.users.currentUser.notification_settings,
newDomainToMute: ''
}
},
created () {
this.$store.dispatch('fetchTokens')
},
components: {
StyleSwitcher,
ScopeSelector,
TabSwitcher,
ImageCropper,
BlockList,
MuteList,
DomainMuteList,
EmojiInput,
Autosuggest,
BlockCard,
MuteCard,
DomainMuteCard,
ProgressButton,
Importer,
Exporter,
Mfa,
Checkbox
},
computed: {
user () {
return this.$store.state.users.currentUser
},
emojiUserSuggestor () {
return suggestor({
emoji: [
...this.$store.state.instance.emoji,
...this.$store.state.instance.customEmoji
],
users: this.$store.state.users.users,
updateUsersList: (input) => this.$store.dispatch('searchUsers', input)
})
},
emojiSuggestor () {
return suggestor({ emoji: [
...this.$store.state.instance.emoji,
...this.$store.state.instance.customEmoji
] })
},
pleromaBackend () {
return this.$store.state.instance.pleromaBackend
},
minimalScopesMode () {
return this.$store.state.instance.minimalScopesMode
},
vis () {
return {
public: { selected: this.newDefaultScope === 'public' },
unlisted: { selected: this.newDefaultScope === 'unlisted' },
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: {
updateProfile () {
this.$store.state.api.backendInteractor
.updateProfile({
params: {
note: this.newBio,
locked: this.newLocked,
// Backend notation.
/* eslint-disable camelcase */
display_name: this.newName,
default_scope: this.newDefaultScope,
no_rich_text: this.newNoRichText,
hide_follows: this.hideFollows,
hide_followers: this.hideFollowers,
discoverable: this.discoverable,
allow_following_move: this.allowFollowingMove,
hide_follows_count: this.hideFollowsCount,
hide_followers_count: this.hideFollowersCount,
show_role: this.showRole
/* eslint-enable camelcase */
} }).then((user) => {
this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user)
})
},
updateNotificationSettings () {
this.$store.state.api.backendInteractor
.updateNotificationSettings({ settings: this.notificationSettings })
},
changeVis (visibility) {
this.newDefaultScope = visibility
},
uploadFile (slot, e) {
const file = e.target.files[0]
if (!file) { return }
if (file.size > this.$store.state.instance[slot + 'limit']) {
const filesize = fileSizeFormatService.fileSizeFormat(file.size)
const allowedsize = fileSizeFormatService.fileSizeFormat(this.$store.state.instance[slot + 'limit'])
this[slot + 'UploadError'] = this.$t('upload.error.base') + ' ' + this.$t('upload.error.file_too_big', { filesize: filesize.num, filesizeunit: filesize.unit, allowedsize: allowedsize.num, allowedsizeunit: allowedsize.unit })
return
}
// eslint-disable-next-line no-undef
const reader = new FileReader()
reader.onload = ({ target }) => {
const img = target.result
this[slot + 'Preview'] = img
this[slot] = file
}
reader.readAsDataURL(file)
},
submitAvatar (cropper, file) {
const that = this
return new Promise((resolve, reject) => {
function updateAvatar (avatar) {
that.$store.state.api.backendInteractor.updateAvatar({ avatar })
.then((user) => {
that.$store.commit('addNewUsers', [user])
that.$store.commit('setCurrentUser', user)
resolve()
})
.catch((err) => {
reject(new Error(that.$t('upload.error.base') + ' ' + err.message))
})
}
if (cropper) {
cropper.getCroppedCanvas().toBlob(updateAvatar, file.type)
} else {
updateAvatar(file)
}
})
},
clearUploadError (slot) {
this[slot + 'UploadError'] = null
},
submitBanner () {
if (!this.bannerPreview) { return }
this.bannerUploading = true
this.$store.state.api.backendInteractor.updateBanner({ banner: this.banner })
.then((user) => {
this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user)
this.bannerPreview = null
})
.catch((err) => {
this.bannerUploadError = this.$t('upload.error.base') + ' ' + err.message
})
.then(() => { this.bannerUploading = false })
},
submitBg () {
if (!this.backgroundPreview) { return }
let background = this.background
this.backgroundUploading = true
this.$store.state.api.backendInteractor.updateBg({ background }).then((data) => {
if (!data.error) {
this.$store.commit('addNewUsers', [data])
this.$store.commit('setCurrentUser', data)
this.backgroundPreview = null
} else {
this.backgroundUploadError = this.$t('upload.error.base') + data.error
}
this.backgroundUploading = false
})
},
importFollows (file) {
return this.$store.state.api.backendInteractor.importFollows({ file })
.then((status) => {
if (!status) {
throw new Error('failed')
}
})
},
importBlocks (file) {
return this.$store.state.api.backendInteractor.importBlocks({ file })
.then((status) => {
if (!status) {
throw new Error('failed')
}
})
},
generateExportableUsersContent (users) {
// Get addresses
return users.map((user) => {
// check is it's a local user
if (user && user.is_local) {
// append the instance address
// eslint-disable-next-line no-undef
return user.screen_name + '@' + location.hostname
}
return user.screen_name
}).join('\n')
},
getFollowsContent () {
return this.$store.state.api.backendInteractor.exportFriends({ id: this.$store.state.users.currentUser.id })
.then(this.generateExportableUsersContent)
},
getBlocksContent () {
return this.$store.state.api.backendInteractor.fetchBlocks()
.then(this.generateExportableUsersContent)
},
confirmDelete () {
this.deletingAccount = true
},
deleteAccount () {
this.$store.state.api.backendInteractor.deleteAccount({ password: this.deleteAccountConfirmPasswordInput })
.then((res) => {
if (res.status === 'success') {
this.$store.dispatch('logout')
this.$router.push({ name: 'root' })
} else {
this.deleteAccountError = res.error
}
})
},
changePassword () {
const params = {
password: this.changePasswordInputs[0],
newPassword: this.changePasswordInputs[1],
newPasswordConfirmation: this.changePasswordInputs[2]
}
this.$store.state.api.backendInteractor.changePassword(params)
.then((res) => {
if (res.status === 'success') {
this.changedPassword = true
this.changePasswordError = false
this.logout()
} else {
this.changedPassword = false
this.changePasswordError = res.error
}
})
},
changeEmail () {
const params = {
email: this.newEmail,
password: this.changeEmailPassword
}
this.$store.state.api.backendInteractor.changeEmail(params)
.then((res) => {
if (res.status === 'success') {
this.changedEmail = true
this.changeEmailError = false
} else {
this.changedEmail = false
this.changeEmailError = res.error
}
})
},
activateTab (tabName) {
this.activeTab = tabName
},
logout () {
this.$store.dispatch('logout')
this.$router.replace('/')
},
revokeToken (id) {
if (window.confirm(`${this.$i18n.t('settings.revoke_token')}?`)) {
this.$store.dispatch('revokeToken', id)
}
},
filterUnblockedUsers (userIds) {
return reject(userIds, (userId) => {
const user = this.$store.getters.findUser(userId)
return !user || user.statusnet_blocking || user.id === this.$store.state.users.currentUser.id
})
},
filterUnMutedUsers (userIds) {
return reject(userIds, (userId) => {
const user = this.$store.getters.findUser(userId)
return !user || user.muted || user.id === this.$store.state.users.currentUser.id
})
},
queryUserIds (query) {
return this.$store.dispatch('searchUsers', query)
.then((users) => map(users, 'id'))
},
blockUsers (ids) {
return this.$store.dispatch('blockUsers', ids)
},
unblockUsers (ids) {
return this.$store.dispatch('unblockUsers', ids)
},
muteUsers (ids) {
return this.$store.dispatch('muteUsers', ids)
},
unmuteUsers (ids) {
return this.$store.dispatch('unmuteUsers', ids)
},
unmuteDomains (domains) {
return this.$store.dispatch('unmuteDomains', domains)
},
muteDomain () {
return this.$store.dispatch('muteDomain', this.newDomainToMute)
.then(() => { this.newDomainToMute = '' })
},
identity (value) {
return value
}
}
}
export default UserSettings

View file

@ -1,716 +0,0 @@
<template>
<div class="settings panel panel-default">
<div class="panel-heading">
<div class="title">
{{ $t('settings.user_settings') }}
</div>
<transition name="fade">
<template v-if="currentSaveStateNotice">
<div
v-if="currentSaveStateNotice.error"
class="alert error"
@click.prevent
>
{{ $t('settings.saving_err') }}
</div>
<div
v-if="!currentSaveStateNotice.error"
class="alert transparent"
@click.prevent
>
{{ $t('settings.saving_ok') }}
</div>
</template>
</transition>
</div>
<div class="panel-body profile-edit">
<tab-switcher>
<div :label="$t('settings.profile_tab')">
<div class="setting-item">
<h2>{{ $t('settings.name_bio') }}</h2>
<p>{{ $t('settings.name') }}</p>
<EmojiInput
v-model="newName"
enable-emoji-picker
:suggest="emojiSuggestor"
>
<input
id="username"
v-model="newName"
classname="name-changer"
>
</EmojiInput>
<p>{{ $t('settings.bio') }}</p>
<EmojiInput
v-model="newBio"
enable-emoji-picker
:suggest="emojiUserSuggestor"
>
<textarea
v-model="newBio"
classname="bio"
/>
</EmojiInput>
<p>
<Checkbox v-model="newLocked">
{{ $t('settings.lock_account_description') }}
</Checkbox>
</p>
<div>
<label for="default-vis">{{ $t('settings.default_vis') }}</label>
<div
id="default-vis"
class="visibility-tray"
>
<scope-selector
:show-all="true"
:user-default="newDefaultScope"
:initial-scope="newDefaultScope"
:on-scope-change="changeVis"
/>
</div>
</div>
<p>
<Checkbox v-model="newNoRichText">
{{ $t('settings.no_rich_text_description') }}
</Checkbox>
</p>
<p>
<Checkbox v-model="hideFollows">
{{ $t('settings.hide_follows_description') }}
</Checkbox>
</p>
<p class="setting-subitem">
<Checkbox
v-model="hideFollowsCount"
:disabled="!hideFollows"
>
{{ $t('settings.hide_follows_count_description') }}
</Checkbox>
</p>
<p>
<Checkbox v-model="hideFollowers">
{{ $t('settings.hide_followers_description') }}
</Checkbox>
</p>
<p class="setting-subitem">
<Checkbox
v-model="hideFollowersCount"
:disabled="!hideFollowers"
>
{{ $t('settings.hide_followers_count_description') }}
</Checkbox>
</p>
<p>
<Checkbox v-model="allowFollowingMove">
{{ $t('settings.allow_following_move') }}
</Checkbox>
</p>
<p v-if="role === 'admin' || role === 'moderator'">
<Checkbox v-model="showRole">
<template v-if="role === 'admin'">
{{ $t('settings.show_admin_badge') }}
</template>
<template v-if="role === 'moderator'">
{{ $t('settings.show_moderator_badge') }}
</template>
</Checkbox>
</p>
<p>
<Checkbox v-model="discoverable">
{{ $t('settings.discoverable') }}
</Checkbox>
</p>
<button
:disabled="newName && newName.length === 0"
class="btn btn-default"
@click="updateProfile"
>
{{ $t('general.submit') }}
</button>
</div>
<div class="setting-item">
<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="current-avatar"
>
<p>{{ $t('settings.set_new_avatar') }}</p>
<button
v-show="pickAvatarBtnVisible"
id="pick-avatar"
class="btn"
type="button"
>
{{ $t('settings.upload_a_photo') }}
</button>
<image-cropper
trigger="#pick-avatar"
:submit-handler="submitAvatar"
@open="pickAvatarBtnVisible=false"
@close="pickAvatarBtnVisible=true"
/>
</div>
<div class="setting-item">
<h2>{{ $t('settings.profile_banner') }}</h2>
<p>{{ $t('settings.current_profile_banner') }}</p>
<img
:src="user.cover_photo"
class="banner"
>
<p>{{ $t('settings.set_new_profile_banner') }}</p>
<img
v-if="bannerPreview"
class="banner"
:src="bannerPreview"
>
<div>
<input
type="file"
@change="uploadFile('banner', $event)"
>
</div>
<i
v-if="bannerUploading"
class=" icon-spin4 animate-spin uploading"
/>
<button
v-else-if="bannerPreview"
class="btn btn-default"
@click="submitBanner"
>
{{ $t('general.submit') }}
</button>
<div
v-if="bannerUploadError"
class="alert error"
>
Error: {{ bannerUploadError }}
<i
class="button-icon icon-cancel"
@click="clearUploadError('banner')"
/>
</div>
</div>
<div class="setting-item">
<h2>{{ $t('settings.profile_background') }}</h2>
<p>{{ $t('settings.set_new_profile_background') }}</p>
<img
v-if="backgroundPreview"
class="bg"
:src="backgroundPreview"
>
<div>
<input
type="file"
@change="uploadFile('background', $event)"
>
</div>
<i
v-if="backgroundUploading"
class=" icon-spin4 animate-spin uploading"
/>
<button
v-else-if="backgroundPreview"
class="btn btn-default"
@click="submitBg"
>
{{ $t('general.submit') }}
</button>
<div
v-if="backgroundUploadError"
class="alert error"
>
Error: {{ backgroundUploadError }}
<i
class="button-icon icon-cancel"
@click="clearUploadError('background')"
/>
</div>
</div>
</div>
<div :label="$t('settings.security_tab')">
<div class="setting-item">
<h2>{{ $t('settings.change_email') }}</h2>
<div>
<p>{{ $t('settings.new_email') }}</p>
<input
v-model="newEmail"
type="email"
autocomplete="email"
>
</div>
<div>
<p>{{ $t('settings.current_password') }}</p>
<input
v-model="changeEmailPassword"
type="password"
autocomplete="current-password"
>
</div>
<button
class="btn btn-default"
@click="changeEmail"
>
{{ $t('general.submit') }}
</button>
<p v-if="changedEmail">
{{ $t('settings.changed_email') }}
</p>
<template v-if="changeEmailError !== false">
<p>{{ $t('settings.change_email_error') }}</p>
<p>{{ changeEmailError }}</p>
</template>
</div>
<div class="setting-item">
<h2>{{ $t('settings.change_password') }}</h2>
<div>
<p>{{ $t('settings.current_password') }}</p>
<input
v-model="changePasswordInputs[0]"
type="password"
>
</div>
<div>
<p>{{ $t('settings.new_password') }}</p>
<input
v-model="changePasswordInputs[1]"
type="password"
>
</div>
<div>
<p>{{ $t('settings.confirm_new_password') }}</p>
<input
v-model="changePasswordInputs[2]"
type="password"
>
</div>
<button
class="btn btn-default"
@click="changePassword"
>
{{ $t('general.submit') }}
</button>
<p v-if="changedPassword">
{{ $t('settings.changed_password') }}
</p>
<p v-else-if="changePasswordError !== false">
{{ $t('settings.change_password_error') }}
</p>
<p v-if="changePasswordError">
{{ changePasswordError }}
</p>
</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 />
</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>
<mfa />
<div class="setting-item">
<h2>{{ $t('settings.delete_account') }}</h2>
<p v-if="!deletingAccount">
{{ $t('settings.delete_account_description') }}
</p>
<div v-if="deletingAccount">
<p>{{ $t('settings.delete_account_instructions') }}</p>
<p>{{ $t('login.password') }}</p>
<input
v-model="deleteAccountConfirmPasswordInput"
type="password"
>
<button
class="btn btn-default"
@click="deleteAccount"
>
{{ $t('settings.delete_account') }}
</button>
</div>
<p v-if="deleteAccountError !== false">
{{ $t('settings.delete_account_error') }}
</p>
<p v-if="deleteAccountError">
{{ deleteAccountError }}
</p>
<button
v-if="!deletingAccount"
class="btn btn-default"
@click="confirmDelete"
>
{{ $t('general.submit') }}
</button>
</div>
</div>
<div
v-if="pleromaBackend"
:label="$t('settings.notifications')"
>
<div class="setting-item">
<div class="select-multiple">
<span class="label">{{ $t('settings.notification_setting') }}</span>
<ul class="option-list">
<li>
<Checkbox v-model="notificationSettings.follows">
{{ $t('settings.notification_setting_follows') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="notificationSettings.followers">
{{ $t('settings.notification_setting_followers') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="notificationSettings.non_follows">
{{ $t('settings.notification_setting_non_follows') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="notificationSettings.non_followers">
{{ $t('settings.notification_setting_non_followers') }}
</Checkbox>
</li>
</ul>
</div>
<p>{{ $t('settings.notification_mutes') }}</p>
<p>{{ $t('settings.notification_blocks') }}</p>
<button
class="btn btn-default"
@click="updateNotificationSettings"
>
{{ $t('general.submit') }}
</button>
</div>
</div>
<div
v-if="pleromaBackend"
:label="$t('settings.data_import_export_tab')"
>
<div class="setting-item">
<h2>{{ $t('settings.follow_import') }}</h2>
<p>{{ $t('settings.import_followers_from_a_csv_file') }}</p>
<Importer
:submit-handler="importFollows"
:success-message="$t('settings.follows_imported')"
:error-message="$t('settings.follow_import_error')"
/>
</div>
<div class="setting-item">
<h2>{{ $t('settings.follow_export') }}</h2>
<Exporter
:get-content="getFollowsContent"
filename="friends.csv"
:export-button-label="$t('settings.follow_export_button')"
/>
</div>
<div class="setting-item">
<h2>{{ $t('settings.block_import') }}</h2>
<p>{{ $t('settings.import_blocks_from_a_csv_file') }}</p>
<Importer
:submit-handler="importBlocks"
:success-message="$t('settings.blocks_imported')"
:error-message="$t('settings.block_import_error')"
/>
</div>
<div class="setting-item">
<h2>{{ $t('settings.block_export') }}</h2>
<Exporter
:get-content="getBlocksContent"
filename="blocks.csv"
:export-button-label="$t('settings.block_export_button')"
/>
</div>
</div>
<div :label="$t('settings.blocks_tab')">
<div class="profile-edit-usersearch-wrapper">
<Autosuggest
:filter="filterUnblockedUsers"
:query="queryUserIds"
:placeholder="$t('settings.search_user_to_block')"
>
<BlockCard
slot-scope="row"
:user-id="row.item"
/>
</Autosuggest>
</div>
<BlockList
:refresh="true"
:get-key="identity"
>
<template
slot="header"
slot-scope="{selected}"
>
<div class="profile-edit-bulk-actions">
<ProgressButton
v-if="selected.length > 0"
class="btn btn-default"
:click="() => blockUsers(selected)"
>
{{ $t('user_card.block') }}
<template slot="progress">
{{ $t('user_card.block_progress') }}
</template>
</ProgressButton>
<ProgressButton
v-if="selected.length > 0"
class="btn btn-default"
:click="() => unblockUsers(selected)"
>
{{ $t('user_card.unblock') }}
<template slot="progress">
{{ $t('user_card.unblock_progress') }}
</template>
</ProgressButton>
</div>
</template>
<template
slot="item"
slot-scope="{item}"
>
<BlockCard :user-id="item" />
</template>
<template slot="empty">
{{ $t('settings.no_blocks') }}
</template>
</BlockList>
</div>
<div :label="$t('settings.mutes_tab')">
<tab-switcher>
<div label="Users">
<div class="profile-edit-usersearch-wrapper">
<Autosuggest
:filter="filterUnMutedUsers"
:query="queryUserIds"
:placeholder="$t('settings.search_user_to_mute')"
>
<MuteCard
slot-scope="row"
:user-id="row.item"
/>
</Autosuggest>
</div>
<MuteList
:refresh="true"
:get-key="identity"
>
<template
slot="header"
slot-scope="{selected}"
>
<div class="profile-edit-bulk-actions">
<ProgressButton
v-if="selected.length > 0"
class="btn btn-default"
:click="() => muteUsers(selected)"
>
{{ $t('user_card.mute') }}
<template slot="progress">
{{ $t('user_card.mute_progress') }}
</template>
</ProgressButton>
<ProgressButton
v-if="selected.length > 0"
class="btn btn-default"
:click="() => unmuteUsers(selected)"
>
{{ $t('user_card.unmute') }}
<template slot="progress">
{{ $t('user_card.unmute_progress') }}
</template>
</ProgressButton>
</div>
</template>
<template
slot="item"
slot-scope="{item}"
>
<MuteCard :user-id="item" />
</template>
<template slot="empty">
{{ $t('settings.no_mutes') }}
</template>
</MuteList>
</div>
<div :label="$t('settings.domain_mutes')">
<div class="profile-edit-domain-mute-form">
<input
v-model="newDomainToMute"
:placeholder="$t('settings.type_domains_to_mute')"
type="text"
@keyup.enter="muteDomain"
>
<ProgressButton
class="btn btn-default"
:click="muteDomain"
>
{{ $t('domain_mute_card.mute') }}
<template slot="progress">
{{ $t('domain_mute_card.mute_progress') }}
</template>
</ProgressButton>
</div>
<DomainMuteList
:refresh="true"
:get-key="identity"
>
<template
slot="header"
slot-scope="{selected}"
>
<div class="profile-edit-bulk-actions">
<ProgressButton
v-if="selected.length > 0"
class="btn btn-default"
:click="() => unmuteDomains(selected)"
>
{{ $t('domain_mute_card.unmute') }}
<template slot="progress">
{{ $t('domain_mute_card.unmute_progress') }}
</template>
</ProgressButton>
</div>
</template>
<template
slot="item"
slot-scope="{item}"
>
<DomainMuteCard :domain="item" />
</template>
<template slot="empty">
{{ $t('settings.no_mutes') }}
</template>
</DomainMuteList>
</div>
</tab-switcher>
</div>
</tab-switcher>
</div>
</div>
</template>
<script src="./user_settings.js">
</script>
<style lang="scss">
@import '../../_variables.scss';
.profile-edit {
.bio {
margin: 0;
}
.visibility-tray {
padding-top: 5px;
}
input[type=file] {
padding: 5px;
height: auto;
}
.banner {
max-width: 100%;
}
.uploading {
font-size: 1.5em;
margin: 0.25em;
}
.name-changer {
width: 100%;
}
.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;
}
}
&-usersearch-wrapper {
padding: 1em;
}
&-bulk-actions {
text-align: right;
padding: 0 1em;
min-height: 28px;
button {
width: 10em;
}
}
&-domain-mute-form {
padding: 1em;
display: flex;
flex-direction: column;
button {
align-self: flex-end;
margin-top: 1em;
width: 10em;
}
}
.setting-subitem {
margin-left: 1.75em;
}
}
</style>

View file

@ -1,206 +1,206 @@
{
"chat": {
"title": "الدردشة"
"chat": {
"title": "الدردشة"
},
"features_panel": {
"chat": "الدردشة",
"gopher": "غوفر",
"media_proxy": "بروكسي الوسائط",
"scope_options": "",
"text_limit": "الحد الأقصى للنص",
"title": "الميّزات",
"who_to_follow": "للمتابعة"
},
"finder": {
"error_fetching_user": "خطأ أثناء جلب صفحة المستخدم",
"find_user": "البحث عن مستخدِم"
},
"general": {
"apply": "تطبيق",
"submit": "إرسال"
},
"login": {
"login": "تسجيل الدخول",
"logout": "الخروج",
"password": "الكلمة السرية",
"placeholder": "مثال lain",
"register": "انشاء حساب",
"username": "إسم المستخدم"
},
"nav": {
"chat": "الدردشة المحلية",
"friend_requests": "طلبات المتابَعة",
"mentions": "الإشارات",
"public_tl": "الخيط الزمني العام",
"timeline": "الخيط الزمني",
"twkn": "كافة الشبكة المعروفة"
},
"notifications": {
"broken_favorite": "منشور مجهول، جارٍ البحث عنه…",
"favorited_you": "أعجِب بمنشورك",
"followed_you": "يُتابعك",
"load_older": "تحميل الإشعارات الأقدم",
"notifications": "الإخطارات",
"read": "مقروء!",
"repeated_you": "شارَك منشورك"
},
"post_status": {
"account_not_locked_warning": "",
"account_not_locked_warning_link": "مقفل",
"attachments_sensitive": "اعتبر المرفقات كلها كمحتوى حساس",
"content_type": {
"text/plain": "نص صافٍ"
},
"features_panel": {
"chat": "الدردشة",
"gopher": "غوفر",
"media_proxy": "بروكسي الوسائط",
"scope_options": "",
"text_limit": "الحد الأقصى للنص",
"title": "الميّزات",
"who_to_follow": "للمتابعة"
},
"finder": {
"error_fetching_user": "خطأ أثناء جلب صفحة المستخدم",
"find_user": "البحث عن مستخدِم"
},
"general": {
"apply": "تطبيق",
"submit": "إرسال"
},
"login": {
"login": "تسجيل الدخول",
"logout": "الخروج",
"password": "الكلمة السرية",
"placeholder": "مثال lain",
"register": "انشاء حساب",
"username": "إسم المستخدم"
},
"nav": {
"chat": "الدردشة المحلية",
"friend_requests": "طلبات المتابَعة",
"mentions": "الإشارات",
"public_tl": "الخيط الزمني العام",
"timeline": "الخيط الزمني",
"twkn": "كافة الشبكة المعروفة"
},
"notifications": {
"broken_favorite": "منشور مجهول، جارٍ البحث عنه…",
"favorited_you": "أعجِب بمنشورك",
"followed_you": "يُتابعك",
"load_older": "تحميل الإشعارات الأقدم",
"notifications": "الإخطارات",
"read": "مقروء!",
"repeated_you": "شارَك منشورك"
},
"post_status": {
"account_not_locked_warning": "",
"account_not_locked_warning_link": "مقفل",
"attachments_sensitive": "اعتبر المرفقات كلها كمحتوى حساس",
"content_type": {
"text/plain": "نص صافٍ"
},
"content_warning": "الموضوع (اختياري)",
"default": "وصلت للتوّ إلى لوس أنجلس.",
"direct_warning": "",
"posting": "النشر",
"scope": {
"direct": "",
"private": "",
"public": "علني - يُنشر على الخيوط الزمنية العمومية",
"unlisted": "غير مُدرَج - لا يُنشَر على الخيوط الزمنية العمومية"
}
},
"registration": {
"bio": "السيرة الذاتية",
"email": "عنوان البريد الإلكتروني",
"fullname": "الإسم المعروض",
"password_confirm": "تأكيد الكلمة السرية",
"registration": "التسجيل",
"token": "رمز الدعوة"
},
"settings": {
"attachmentRadius": "المُرفَقات",
"attachments": "المُرفَقات",
"autoload": "",
"avatar": "الصورة الرمزية",
"avatarAltRadius": "الصور الرمزية (الإشعارات)",
"avatarRadius": "الصور الرمزية",
"background": "الخلفية",
"bio": "السيرة الذاتية",
"btnRadius": "الأزرار",
"cBlue": "أزرق (الرد، المتابَعة)",
"cGreen": "أخضر (إعادة النشر)",
"cOrange": "برتقالي (مفضلة)",
"cRed": "أحمر (إلغاء)",
"change_password": "تغيير كلمة السر",
"change_password_error": "وقع هناك خلل أثناء تعديل كلمتك السرية.",
"changed_password": "تم تغيير كلمة المرور بنجاح!",
"collapse_subject": "",
"confirm_new_password": "تأكيد كلمة السر الجديدة",
"current_avatar": "صورتك الرمزية الحالية",
"current_password": "كلمة السر الحالية",
"current_profile_banner": "الرأسية الحالية لصفحتك الشخصية",
"data_import_export_tab": "تصدير واستيراد البيانات",
"default_vis": "أسلوب العرض الافتراضي",
"delete_account": "حذف الحساب",
"delete_account_description": "حذف حسابك و كافة منشوراتك نهائيًا.",
"delete_account_error": "",
"delete_account_instructions": "يُرجى إدخال كلمتك السرية أدناه لتأكيد عملية حذف الحساب.",
"export_theme": "حفظ النموذج",
"filtering": "التصفية",
"filtering_explanation": "سيتم إخفاء كافة المنشورات التي تحتوي على هذه الكلمات، كلمة واحدة في كل سطر",
"follow_export": "تصدير الاشتراكات",
"follow_export_button": "تصدير الاشتراكات كملف csv",
"follow_export_processing": "التصدير جارٍ، سوف يُطلَب منك تنزيل ملفك بعد حين",
"follow_import": "استيراد الاشتراكات",
"follow_import_error": "خطأ أثناء استيراد المتابِعين",
"follows_imported": "",
"foreground": "الأمامية",
"general": "الإعدادات العامة",
"hide_attachments_in_convo": "إخفاء المرفقات على المحادثات",
"hide_attachments_in_tl": "إخفاء المرفقات على الخيط الزمني",
"hide_post_stats": "",
"hide_user_stats": "",
"import_followers_from_a_csv_file": "",
"import_theme": "تحميل نموذج",
"inputRadius": "",
"instance_default": "",
"interfaceLanguage": "لغة الواجهة",
"invalid_theme_imported": "",
"limited_availability": "غير متوفر على متصفحك",
"links": "الروابط",
"lock_account_description": "",
"loop_video": "",
"loop_video_silent_only": "",
"name": "الاسم",
"name_bio": "الاسم والسيرة الذاتية",
"new_password": "كلمة السر الجديدة",
"no_rich_text_description": "",
"notification_visibility": "نوع الإشعارات التي تريد عرضها",
"notification_visibility_follows": "يتابع",
"notification_visibility_likes": "الإعجابات",
"notification_visibility_mentions": "الإشارات",
"notification_visibility_repeats": "",
"nsfw_clickthrough": "",
"oauth_tokens": "رموز OAuth",
"token": "رمز",
"refresh_token": "رمز التحديث",
"valid_until": "صالح حتى",
"revoke_token": "سحب",
"panelRadius": "",
"pause_on_unfocused": "",
"presets": "النماذج",
"profile_background": "خلفية الصفحة الشخصية",
"profile_banner": "رأسية الصفحة الشخصية",
"profile_tab": "الملف الشخصي",
"radii_help": "",
"replies_in_timeline": "الردود على الخيط الزمني",
"reply_link_preview": "",
"reply_visibility_all": "عرض كافة الردود",
"reply_visibility_following": "",
"reply_visibility_self": "",
"saving_err": "خطأ أثناء حفظ الإعدادات",
"saving_ok": "تم حفظ الإعدادات",
"security_tab": "الأمان",
"set_new_avatar": "اختيار صورة رمزية جديدة",
"set_new_profile_background": "اختيار خلفية جديدة للملف الشخصي",
"set_new_profile_banner": "اختيار رأسية جديدة للصفحة الشخصية",
"settings": "الإعدادات",
"stop_gifs": "",
"streaming": "",
"text": "النص",
"theme": "المظهر",
"theme_help": "",
"tooltipRadius": "",
"user_settings": "إعدادات المستخدم",
"values": {
"false": "لا",
"true": "نعم"
}
},
"timeline": {
"collapse": "",
"conversation": "محادثة",
"error_fetching": "خطأ أثناء جلب التحديثات",
"load_older": "تحميل المنشورات القديمة",
"no_retweet_hint": "",
"repeated": "",
"show_new": "عرض الجديد",
"up_to_date": "تم تحديثه"
},
"user_card": {
"approve": "قبول",
"block": "حظر",
"blocked": "تم حظره!",
"deny": "رفض",
"follow": "اتبع",
"followees": "",
"followers": "مُتابِعون",
"following": "",
"follows_you": "يتابعك!",
"mute": "كتم",
"muted": "تم كتمه",
"per_day": "في اليوم",
"remote_follow": "مُتابَعة عن بُعد",
"statuses": "المنشورات"
},
"user_profile": {
"timeline_title": "الخيط الزمني للمستخدم"
},
"who_to_follow": {
"more": "المزيد",
"who_to_follow": "للمتابعة"
"content_warning": "الموضوع (اختياري)",
"default": "وصلت للتوّ إلى لوس أنجلس.",
"direct_warning": "",
"posting": "النشر",
"scope": {
"direct": "",
"private": "",
"public": "علني - يُنشر على الخيوط الزمنية العمومية",
"unlisted": "غير مُدرَج - لا يُنشَر على الخيوط الزمنية العمومية"
}
}
},
"registration": {
"bio": "السيرة الذاتية",
"email": "عنوان البريد الإلكتروني",
"fullname": "الإسم المعروض",
"password_confirm": "تأكيد الكلمة السرية",
"registration": "التسجيل",
"token": "رمز الدعوة"
},
"settings": {
"attachmentRadius": "المُرفَقات",
"attachments": "المُرفَقات",
"autoload": "",
"avatar": "الصورة الرمزية",
"avatarAltRadius": "الصور الرمزية (الإشعارات)",
"avatarRadius": "الصور الرمزية",
"background": "الخلفية",
"bio": "السيرة الذاتية",
"btnRadius": "الأزرار",
"cBlue": "أزرق (الرد، المتابَعة)",
"cGreen": "أخضر (إعادة النشر)",
"cOrange": "برتقالي (مفضلة)",
"cRed": "أحمر (إلغاء)",
"change_password": "تغيير كلمة السر",
"change_password_error": "وقع هناك خلل أثناء تعديل كلمتك السرية.",
"changed_password": "تم تغيير كلمة المرور بنجاح!",
"collapse_subject": "",
"confirm_new_password": "تأكيد كلمة السر الجديدة",
"current_avatar": "صورتك الرمزية الحالية",
"current_password": "كلمة السر الحالية",
"current_profile_banner": "الرأسية الحالية لصفحتك الشخصية",
"data_import_export_tab": "تصدير واستيراد البيانات",
"default_vis": "أسلوب العرض الافتراضي",
"delete_account": "حذف الحساب",
"delete_account_description": "حذف حسابك و كافة منشوراتك نهائيًا.",
"delete_account_error": "",
"delete_account_instructions": "يُرجى إدخال كلمتك السرية أدناه لتأكيد عملية حذف الحساب.",
"export_theme": "حفظ النموذج",
"filtering": "التصفية",
"filtering_explanation": "سيتم إخفاء كافة المنشورات التي تحتوي على هذه الكلمات، كلمة واحدة في كل سطر",
"follow_export": "تصدير الاشتراكات",
"follow_export_button": "تصدير الاشتراكات كملف csv",
"follow_export_processing": "التصدير جارٍ، سوف يُطلَب منك تنزيل ملفك بعد حين",
"follow_import": "استيراد الاشتراكات",
"follow_import_error": "خطأ أثناء استيراد المتابِعين",
"follows_imported": "",
"foreground": "الأمامية",
"general": "الإعدادات العامة",
"hide_attachments_in_convo": "إخفاء المرفقات على المحادثات",
"hide_attachments_in_tl": "إخفاء المرفقات على الخيط الزمني",
"hide_post_stats": "",
"hide_user_stats": "",
"import_followers_from_a_csv_file": "",
"import_theme": "تحميل نموذج",
"inputRadius": "",
"instance_default": "",
"interfaceLanguage": "لغة الواجهة",
"invalid_theme_imported": "",
"limited_availability": "غير متوفر على متصفحك",
"links": "الروابط",
"lock_account_description": "",
"loop_video": "",
"loop_video_silent_only": "",
"name": "الاسم",
"name_bio": "الاسم والسيرة الذاتية",
"new_password": "كلمة السر الجديدة",
"no_rich_text_description": "",
"notification_visibility": "نوع الإشعارات التي تريد عرضها",
"notification_visibility_follows": "يتابع",
"notification_visibility_likes": "الإعجابات",
"notification_visibility_mentions": "الإشارات",
"notification_visibility_repeats": "",
"nsfw_clickthrough": "",
"oauth_tokens": "رموز OAuth",
"token": "رمز",
"refresh_token": "رمز التحديث",
"valid_until": "صالح حتى",
"revoke_token": "سحب",
"panelRadius": "",
"pause_on_unfocused": "",
"presets": "النماذج",
"profile_background": "خلفية الصفحة الشخصية",
"profile_banner": "رأسية الصفحة الشخصية",
"profile_tab": "الملف الشخصي",
"radii_help": "",
"replies_in_timeline": "الردود على الخيط الزمني",
"reply_link_preview": "",
"reply_visibility_all": "عرض كافة الردود",
"reply_visibility_following": "",
"reply_visibility_self": "",
"saving_err": "خطأ أثناء حفظ الإعدادات",
"saving_ok": "تم حفظ الإعدادات",
"security_tab": "الأمان",
"set_new_avatar": "اختيار صورة رمزية جديدة",
"set_new_profile_background": "اختيار خلفية جديدة للملف الشخصي",
"set_new_profile_banner": "اختيار رأسية جديدة للصفحة الشخصية",
"settings": "الإعدادات",
"stop_gifs": "",
"streaming": "",
"text": "النص",
"theme": "المظهر",
"theme_help": "",
"tooltipRadius": "",
"user_settings": "إعدادات المستخدم",
"values": {
"false": "لا",
"true": "نعم"
}
},
"timeline": {
"collapse": "",
"conversation": "محادثة",
"error_fetching": "خطأ أثناء جلب التحديثات",
"load_older": "تحميل المنشورات القديمة",
"no_retweet_hint": "",
"repeated": "",
"show_new": "عرض الجديد",
"up_to_date": "تم تحديثه"
},
"user_card": {
"approve": "قبول",
"block": "حظر",
"blocked": "تم حظره!",
"deny": "رفض",
"follow": "اتبع",
"followees": "",
"followers": "مُتابِعون",
"following": "",
"follows_you": "يتابعك!",
"mute": "كتم",
"muted": "تم كتمه",
"per_day": "في اليوم",
"remote_follow": "مُتابَعة عن بُعد",
"statuses": "المنشورات"
},
"user_profile": {
"timeline_title": "الخيط الزمني للمستخدم"
},
"who_to_follow": {
"more": "المزيد",
"who_to_follow": "للمتابعة"
}
}

View file

@ -7,8 +7,8 @@
"gopher": "Gopher",
"media_proxy": "Medienproxy",
"scope_options": "Reichweitenoptionen",
"text_limit": "Textlimit",
"title": "Features",
"text_limit": "Zeichenlimit",
"title": "Funktionen",
"who_to_follow": "Wem folgen?"
},
"finder": {
@ -17,7 +17,18 @@
},
"general": {
"apply": "Anwenden",
"submit": "Absenden"
"submit": "Absenden",
"more": "Mehr",
"generic_error": "Ein Fehler ist aufgetreten",
"optional": "Optional",
"show_more": "Zeige mehr",
"show_less": "Zeige weniger",
"dismiss": "Ablehnen",
"cancel": "Abbrechen",
"disable": "Deaktivieren",
"enable": "Aktivieren",
"confirm": "Bestätigen",
"verify": "Verifizieren"
},
"login": {
"login": "Anmelden",
@ -26,7 +37,16 @@
"password": "Passwort",
"placeholder": "z.B. lain",
"register": "Registrieren",
"username": "Benutzername"
"username": "Benutzername",
"authentication_code": "Authentifizierungscode",
"enter_recovery_code": "Gebe einen Wiederherstellungscode ein",
"recovery_code": "Wiederherstellungscode",
"heading": {
"totp": "Zwei-Faktor Authentifizierung",
"recovery": "Zwei-Faktor Wiederherstellung"
},
"hint": "Anmelden um an der Diskussion teilzunehmen",
"enter_two_factor_code": "Gebe einen Zwei-Faktor-Code ein"
},
"nav": {
"about": "Über",
@ -41,7 +61,9 @@
"twkn": "Das gesamte bekannte Netzwerk",
"user_search": "Benutzersuche",
"search": "Suche",
"preferences": "Voreinstellungen"
"preferences": "Voreinstellungen",
"administration": "Administration",
"who_to_follow": "Wem folgen"
},
"notifications": {
"broken_favorite": "Unbekannte Nachricht, suche danach...",
@ -50,7 +72,11 @@
"load_older": "Ältere Benachrichtigungen laden",
"notifications": "Benachrichtigungen",
"read": "Gelesen!",
"repeated_you": "wiederholte deine Nachricht"
"repeated_you": "wiederholte deine Nachricht",
"follow_request": "möchte dir folgen",
"migrated_to": "migrierte zu",
"reacted_with": "reagierte mit {0}",
"no_more_notifications": "Keine Benachrichtigungen mehr"
},
"post_status": {
"new_status": "Neuen Status veröffentlichen",
@ -58,7 +84,10 @@
"account_not_locked_warning_link": "gesperrt",
"attachments_sensitive": "Anhänge als heikel markieren",
"content_type": {
"text/plain": "Nur Text"
"text/plain": "Nur Text",
"text/bbcode": "BBCode",
"text/markdown": "Markdown",
"text/html": "HTML"
},
"content_warning": "Betreff (optional)",
"default": "Sitze gerade im Hofbräuhaus.",
@ -69,6 +98,13 @@
"private": "Nur Follower - Beitrag nur für Follower sichtbar",
"public": "Öffentlich - Beitrag an öffentliche Zeitleisten",
"unlisted": "Nicht gelistet - Nicht in öffentlichen Zeitleisten anzeigen"
},
"direct_warning_to_all": "Dieser Beitrag wird für alle erwähnten Benutzer sichtbar sein.",
"direct_warning_to_first_only": "Dieser Beitrag wird für alle Benutzer, die am Anfang der Nachricht erwähnt wurden, sichtbar sein.",
"scope_notice": {
"public": "Dieser Beitrag wird für alle sichtbar sein",
"private": "Dieser Beitrag wird nur für deine Follower sichtbar sein",
"unlisted": "Dieser Beitrag wird weder in der öffentlichen Zeitleiste noch im gesamten bekannten Netzwerk sichtbar sein"
}
},
"registration": {
@ -86,8 +122,11 @@
"email_required": "darf nicht leer sein",
"password_required": "darf nicht leer sein",
"password_confirmation_required": "darf nicht leer sein",
"password_confirmation_match": "sollte mit dem Passwort identisch sein."
}
"password_confirmation_match": "sollte mit dem Passwort identisch sein"
},
"bio_placeholder": "z.B.\nHallo, ich bin Lain.\nIch bin ein Anime Mödchen aus dem vorstädtischen Japan. Du kennst mich vielleicht vom Wired.",
"fullname_placeholder": "z.B. Lain Iwakura",
"username_placeholder": "z.B. lain"
},
"settings": {
"attachmentRadius": "Anhänge",
@ -99,7 +138,7 @@
"background": "Hintergrund",
"bio": "Bio",
"btnRadius": "Buttons",
"cBlue": "Blau (Antworten, Folgt dir)",
"cBlue": "Blau (Antworten, folgt dir)",
"cGreen": "Grün (Retweet)",
"cOrange": "Orange (Favorisieren)",
"cRed": "Rot (Abbrechen)",
@ -115,21 +154,21 @@
"data_import_export_tab": "Datenimport/-export",
"default_vis": "Standard-Sichtbarkeitsumfang",
"delete_account": "Account löschen",
"delete_account_description": "Lösche deinen Account und alle deine Nachrichten unwiderruflich.",
"delete_account_description": "Lösche deine Daten und deaktiviere deinen Account unwiderruflich.",
"delete_account_error": "Es ist ein Fehler beim Löschen deines Accounts aufgetreten. Tritt dies weiterhin auf, wende dich an den Administrator der Instanz.",
"delete_account_instructions": "Tippe dein Passwort unten in das Feld ein, um die Löschung deines Accounts zu bestätigen.",
"discoverable": "Erlaubnis für automatisches Suchen nach diesem Account",
"discoverable": "Erlaube, dass dieser Account in Suchergebnissen auftaucht",
"avatar_size_instruction": "Die empfohlene minimale Größe für Avatare ist 150x150 Pixel.",
"pad_emoji": "Emojis mit Leerzeichen umrahmen",
"export_theme": "Farbschema speichern",
"filtering": "Filtern",
"filtering_explanation": "Alle Beiträge die diese Wörter enthalten werden ausgeblendet. Ein Wort pro Zeile.",
"filtering_explanation": "Alle Beiträge, welche diese Wörter enthalten, werden ausgeblendet. Ein Wort pro Zeile.",
"follow_export": "Follower exportieren",
"follow_export_button": "Exportiere deine Follows in eine csv-Datei",
"follow_export_processing": "In Bearbeitung. Die Liste steht gleich zum herunterladen bereit.",
"follow_import": "Followers importieren",
"follow_import_error": "Fehler beim importieren der Follower",
"follows_imported": "Followers importiert! Die Bearbeitung kann eine Zeit lang dauern.",
"follow_import": "Follower importieren",
"follow_import_error": "Fehler beim Importieren der Follower",
"follows_imported": "Follower importiert! Die Bearbeitung kann einen Moment dauern.",
"foreground": "Vordergrund",
"general": "Allgemein",
"hide_attachments_in_convo": "Anhänge in Unterhaltungen ausblenden",
@ -142,7 +181,7 @@
"hide_post_stats": "Beitragsstatistiken verbergen (z.B. die Anzahl der Favoriten)",
"hide_user_stats": "Benutzerstatistiken verbergen (z.B. die Anzahl der Follower)",
"hide_filtered_statuses": "Gefilterte Beiträge verbergen",
"import_followers_from_a_csv_file": "Importiere Follower, denen du folgen möchtest, aus einer CSV-Datei",
"import_followers_from_a_csv_file": "Importiere Follower aus einer CSV-Datei",
"import_theme": "Farbschema laden",
"inputRadius": "Eingabefelder",
"checkboxRadius": "Auswahlfelder",
@ -156,7 +195,7 @@
"lock_account_description": "Sperre deinen Account, um neue Follower zu genehmigen oder abzulehnen",
"loop_video": "Videos wiederholen",
"loop_video_silent_only": "Nur Videos ohne Ton wiederholen (z.B. Mastodons \"gifs\")",
"mutes_tab": "Mutes",
"mutes_tab": "Stummschaltungen",
"play_videos_in_modal": "Videos in größerem Medienfenster abspielen",
"use_contain_fit": "Vorschaubilder nicht zuschneiden",
"name": "Name",
@ -221,7 +260,7 @@
},
"notifications": "Benachrichtigungen",
"enable_web_push_notifications": "Web-Pushbenachrichtigungen aktivieren",
"style": {
"style": {
"switcher": {
"keep_color": "Farben beibehalten",
"keep_shadows": "Schatten beibehalten",
@ -329,7 +368,43 @@
"checkbox": "Ich habe die Allgemeinen Geschäftsbedingungen überflogen",
"link": "ein netter kleiner Link"
}
}
},
"app_name": "Anwendungsname",
"mfa": {
"otp": "OTP",
"recovery_codes_warning": "Schreibe dir die Codes auf oder speichere sie an einem sicheren Ort - ansonsten wirst du sie nicht wiederfinden. Wenn du den Zugriff zu deiner 2FA App und die Wiederherstellungs-Codes verlierst, wirst du aus deinem Account ausgeschlossen sein.",
"recovery_codes": "Wiederherstellungs-Codes.",
"warning_of_generate_new_codes": "Wenn du neue Wiederherstellungs-Codes generierst, werden die alten Codes nicht mehr funktionieren.",
"generate_new_recovery_codes": "Generiere neue Wiederherstellungs-Codes",
"title": "Zwei-Faktor Authentifizierung",
"waiting_a_recovery_codes": "Erhalte Wiederherstellungscodes...",
"authentication_methods": "Authentifizierungsmethoden",
"scan": {
"title": "Scan",
"secret_code": "Schlüssel",
"desc": "Wenn du deine 2FA App verwendest, scanne diesen QR Code oder gebe den Schlüssel ein:"
},
"verify": {
"desc": "Um 2FA zu aktivieren, gib den Code von deiner 2FA-App ein:"
}
},
"enter_current_password_to_confirm": "Gib dein aktuelles Passwort ein, um deine Identität zu bestätigen",
"security": "Sicherheit",
"allow_following_move": "Erlaube automatisches Folgen, sobald ein gefolgter Nutzer umzieht",
"blocks_imported": "Blocks importiert! Die Verarbeitung wird einen Moment brauchen.",
"block_import_error": "Fehler beim Importieren der Blocks",
"block_import": "Block Import",
"block_export_button": "Exportiere deine Blocks in eine csv Datei",
"block_export": "Block Export",
"emoji_reactions_on_timeline": "Zeige Emoji-Reaktionen auf der Zeitleiste",
"domain_mutes": "Domains",
"changed_email": "Email Adresse erfolgreich geändert!",
"change_email_error": "Es trat ein Problem auf beim Versuch, deine Email Adresse zu ändern.",
"change_email": "Ändere Email",
"notification_setting_non_followers": "Nutzer, die dir nicht folgen",
"notification_setting_followers": "Nutzer, die dir folgen",
"import_blocks_from_a_csv_file": "Importiere Blocks von einer CSV Datei",
"accent": "Akzent"
},
"timeline": {
"collapse": "Einklappen",
@ -352,7 +427,7 @@
"follow_again": "Anfrage erneut senden?",
"follow_unfollow": "Folgen beenden",
"followees": "Folgt",
"followers": "Followers",
"followers": "Folgende",
"following": "Folgst du!",
"follows_you": "Folgt dir!",
"its_you": "Das bist du!",
@ -360,7 +435,10 @@
"muted": "Stummgeschaltet",
"per_day": "pro Tag",
"remote_follow": "Folgen",
"statuses": "Beiträge"
"statuses": "Beiträge",
"admin_menu": {
"sandbox": "Erzwinge Beiträge nur für Follower sichtbar zu sein"
}
},
"user_profile": {
"timeline_title": "Beiträge"
@ -376,11 +454,11 @@
"favorite": "Favorisieren",
"user_settings": "Benutzereinstellungen"
},
"upload":{
"upload": {
"error": {
"base": "Hochladen fehlgeschlagen.",
"file_too_big": "Datei ist zu groß [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]",
"default": "Bitte versuche es später erneut"
"base": "Hochladen fehlgeschlagen.",
"file_too_big": "Datei ist zu groß [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]",
"default": "Bitte versuche es später erneut"
},
"file_size_units": {
"B": "B",
@ -409,5 +487,98 @@
"password_reset_disabled": "Passwortzurücksetzen deaktiviert. Bitte Administrator kontaktieren.",
"password_reset_required": "Passwortzurücksetzen erforderlich",
"password_reset_required_but_mailer_is_disabled": "Passwortzurücksetzen wäre erforderlich, ist aber deaktiviert. Bitte Administrator kontaktieren."
},
"about": {
"mrf": {
"federation": "Föderation",
"mrf_policies": "Aktivierte MRF Richtlinien",
"simple": {
"simple_policies": "Instanzspezifische Richtlinien",
"accept": "Akzeptieren",
"reject": "Ablehnen",
"reject_desc": "Diese Instanz akzeptiert keine Nachrichten der folgenden Instanzen:",
"quarantine": "Quarantäne",
"ftl_removal": "Von der Zeitleiste \"Das gesamte bekannte Netzwerk\" entfernen",
"media_removal": "Medienentfernung",
"media_removal_desc": "Diese Instanz entfernt Medien von den Beiträgen der folgenden Instanzen:",
"media_nsfw": "Erzwingen Medien als heikel zu makieren",
"media_nsfw_desc": "Diese Instanz makiert die Medien in Beiträgen der folgenden Instanzen als heikel:",
"accept_desc": "Diese Instanz akzeptiert nur Nachrichten von den folgenden Instanzen:",
"quarantine_desc": "Diese Instanz sendet nur öffentliche Beiträge zu den folgenden Instanzen:",
"ftl_removal_desc": "Dieser Instanz entfernt folgende Instanzen von der \"Das gesamte bekannte Netzwerk\" Zeitleiste:"
},
"keyword": {
"keyword_policies": "Keyword Richtlinien",
"reject": "Ablehnen",
"replace": "Ersetzen",
"is_replaced_by": "→",
"ftl_removal": "Von der Zeitleiste \"Das gesamte bekannte Netzwerk\" entfernen"
},
"mrf_policies_desc": "MRF Richtlinien manipulieren das Föderationsverhalten dieser Instanz. Die folgenden Richtlinien sind aktiv:"
},
"staff": "Mitarbeiter"
},
"domain_mute_card": {
"mute": "Stummschalten",
"mute_progress": "Wird stummgeschaltet..",
"unmute": "Stummschaltung aufheben",
"unmute_progress": "Stummschaltung wird aufgehoben.."
},
"exporter": {
"export": "Exportieren",
"processing": "Verarbeitung läuft, bald wird Du dazu aufgefordert, deine Datei herunterzuladen"
},
"image_cropper": {
"crop_picture": "Bild zuschneiden",
"save": "Speichern",
"cancel": "Abbrechen",
"save_without_cropping": "Ohne Zuschneiden speichern"
},
"importer": {
"submit": "Absenden",
"success": "Erfolgreich importiert.",
"error": "Ein Fehler ist beim Verabeiten der Datei aufgetreten."
},
"media_modal": {
"previous": "Zurück",
"next": "Weiter"
},
"polls": {
"add_poll": "Umfrage hinzufügen",
"add_option": "Option hinzufügen",
"option": "Option",
"votes": "Stimmen",
"vote": "Abstimmen",
"type": "Umfragetyp",
"multiple_choices": "Mehrere Auswahlmöglichkeiten",
"single_choice": "Eine Auswahlmöglichkeit",
"expiry": "Alter der Umfrage",
"expired": "Die Umfrage endete vor {0}",
"not_enough_options": "Zu wenig einzigartige Auswahlmöglichkeiten in der Umfrage",
"expires_in": "Die Umfrage endet in {0}"
},
"emoji": {
"stickers": "Sticker",
"emoji": "Emoji",
"search_emoji": "Nach einem Emoji suchen",
"custom": "Benutzerdefinierter Emoji",
"keep_open": "Auswahlfenster offen halten",
"add_emoji": "Emoji einfügen",
"load_all": "Lade alle {emojiAmount} Emoji",
"load_all_hint": "Erfolgreich erste {saneAmount} Emoji geladen, alle Emojis zu laden würde Leistungsprobleme hervorrufen.",
"unicode": "Unicode Emoji"
},
"interactions": {
"load_older": "Lade ältere Interaktionen",
"follows": "Neue Follows",
"favs_repeats": "Wiederholungen und Favoriten",
"moves": "Benutzer migriert zu"
},
"selectable_list": {
"select_all": "Wähle alle"
},
"remote_user_resolver": {
"searching_for": "Suche nach",
"error": "Nicht gefunden."
}
}

View file

@ -34,9 +34,9 @@
},
"domain_mute_card": {
"mute": "Mute",
"mute_progress": "Muting...",
"mute_progress": "Muting",
"unmute": "Unmute",
"unmute_progress": "Unmuting..."
"unmute_progress": "Unmuting"
},
"exporter": {
"export": "Export",
@ -59,7 +59,10 @@
"apply": "Apply",
"submit": "Submit",
"more": "More",
"loading": "Loading…",
"generic_error": "An error occured",
"error_retry": "Please try again",
"retry": "Try again",
"optional": "optional",
"show_more": "Show more",
"show_less": "Show less",
@ -68,7 +71,9 @@
"disable": "Disable",
"enable": "Enable",
"confirm": "Confirm",
"verify": "Verify"
"verify": "Verify",
"close": "Close",
"peek": "Peek"
},
"image_cropper": {
"crop_picture": "Crop picture",
@ -94,9 +99,9 @@
"enter_recovery_code": "Enter a recovery code",
"enter_two_factor_code": "Enter a two-factor code",
"recovery_code": "Recovery code",
"heading" : {
"totp" : "Two-factor authentication",
"recovery" : "Two-factor recovery"
"heading": {
"totp": "Two-factor authentication",
"recovery": "Two-factor recovery"
}
},
"media_modal": {
@ -121,9 +126,10 @@
"preferences": "Preferences"
},
"notifications": {
"broken_favorite": "Unknown status, searching for it...",
"broken_favorite": "Unknown status, searching for it",
"favorited_you": "favorited your status",
"followed_you": "followed you",
"follow_request": "wants to follow you",
"load_older": "Load older notifications",
"notifications": "Notifications",
"read": "Read!",
@ -225,17 +231,17 @@
"security": "Security",
"enter_current_password_to_confirm": "Enter your current password to confirm your identity",
"mfa": {
"otp" : "OTP",
"setup_otp" : "Setup OTP",
"wait_pre_setup_otp" : "presetting OTP",
"confirm_and_enable" : "Confirm & enable OTP",
"otp": "OTP",
"setup_otp": "Setup OTP",
"wait_pre_setup_otp": "presetting OTP",
"confirm_and_enable": "Confirm & enable OTP",
"title": "Two-factor Authentication",
"generate_new_recovery_codes" : "Generate new recovery codes",
"warning_of_generate_new_codes" : "When you generate new recovery codes, your old codes wont work anymore.",
"recovery_codes" : "Recovery codes.",
"waiting_a_recovery_codes": "Receiving backup codes...",
"recovery_codes_warning" : "Write the codes down or save them somewhere secure - otherwise you won't see them again. If you lose access to your 2FA app and recovery codes you'll be locked out of your account.",
"authentication_methods" : "Authentication methods",
"generate_new_recovery_codes": "Generate new recovery codes",
"warning_of_generate_new_codes": "When you generate new recovery codes, your old codes wont work anymore.",
"recovery_codes": "Recovery codes.",
"waiting_a_recovery_codes": "Receiving backup codes",
"recovery_codes_warning": "Write the codes down or save them somewhere secure - otherwise you won't see them again. If you lose access to your 2FA app and recovery codes you'll be locked out of your account.",
"authentication_methods": "Authentication methods",
"scan": {
"title": "Scan",
"desc": "Using your two-factor app, scan this QR code or enter text key:",
@ -277,10 +283,11 @@
"current_avatar": "Your current avatar",
"current_password": "Current password",
"current_profile_banner": "Your current profile banner",
"mutes_and_blocks": "Mutes and Blocks",
"data_import_export_tab": "Data Import / Export",
"default_vis": "Default visibility scope",
"delete_account": "Delete Account",
"delete_account_description": "Permanently delete your account and all your messages.",
"delete_account_description": "Permanently delete your data and deactivate your account.",
"delete_account_error": "There was an issue deleting your account. If this persists please contact your instance administrator.",
"delete_account_instructions": "Type your password in the input below to confirm account deletion.",
"discoverable": "Allow discovery of this account in search results and other services",
@ -394,7 +401,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",
"type_domains_to_mute": "Type in domains to mute",
"type_domains_to_mute": "Search domains to mute",
"upload_a_photo": "Upload a photo",
"user_settings": "User Settings",
"values": {
@ -404,11 +411,14 @@
"fun": "Fun",
"greentext": "Meme arrows",
"notifications": "Notifications",
"notification_setting_filters": "Filters",
"notification_setting": "Receive notifications from:",
"notification_setting_follows": "Users you follow",
"notification_setting_non_follows": "Users you do not follow",
"notification_setting_followers": "Users who follow you",
"notification_setting_non_followers": "Users who do not follow you",
"notification_setting_privacy": "Privacy",
"notification_setting_privacy_option": "Hide the sender and contents of push notifications",
"notification_mutes": "To stop receiving notifications from a specific user, use a mute.",
"notification_blocks": "Blocking a user stops all notifications as well as unsubscribes them.",
"enable_web_push_notifications": "Enable web push notifications",
@ -429,7 +439,7 @@
"use_source": "New version",
"help": {
"upgraded_from_v2": "PleromaFE has been upgraded, theme could look a little bit different than you remember.",
"v2_imported": "File you imported was made for older FE. We try to maximize compatibility but there still could be inconsitencies.",
"v2_imported": "File you imported was made for older FE. We try to maximize compatibility but there still could be inconsistencies.",
"future_version_imported": "File you imported was made in newer version of FE.",
"older_version_imported": "File you imported was made in older version of FE.",
"snapshot_present": "Theme snapshot is loaded, so all values are overriden. You can load theme's actual data instead.",
@ -615,7 +625,11 @@
"reply_to": "Reply to",
"replies_list": "Replies:",
"mute_conversation": "Mute conversation",
"unmute_conversation": "Unmute conversation"
"unmute_conversation": "Unmute conversation",
"status_unavailable": "Status unavailable",
"copy_link": "Copy link to status",
"thread_muted": "Thread muted",
"thread_muted_and_words": ", has words:"
},
"user_card": {
"approve": "Approve",
@ -645,11 +659,11 @@
"subscribe": "Subscribe",
"unsubscribe": "Unsubscribe",
"unblock": "Unblock",
"unblock_progress": "Unblocking...",
"block_progress": "Blocking...",
"unblock_progress": "Unblocking",
"block_progress": "Blocking",
"unmute": "Unmute",
"unmute_progress": "Unmuting...",
"mute_progress": "Muting...",
"unmute_progress": "Unmuting",
"mute_progress": "Muting",
"hide_repeats": "Hide repeats",
"show_repeats": "Show repeats",
"admin_menu": {
@ -696,9 +710,11 @@
"reply": "Reply",
"favorite": "Favorite",
"add_reaction": "Add Reaction",
"user_settings": "User Settings"
"user_settings": "User Settings",
"accept_follow_request": "Accept follow request",
"reject_follow_request": "Reject follow request"
},
"upload":{
"upload": {
"error": {
"base": "Upload failed.",
"file_too_big": "File too big [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]",

View file

@ -57,12 +57,12 @@
"enter_recovery_code": "Inserta el código de recuperación",
"enter_two_factor_code": "Inserta el código de dos factores",
"recovery_code": "Código de recuperación",
"heading" : {
"totp" : "Autenticación de dos factores",
"recovery" : "Recuperación de dos factores"
"heading": {
"totp": "Autenticación de dos factores",
"recovery": "Recuperación de dos factores"
}
},
"media_modal": {
"media_modal": {
"previous": "Anterior",
"next": "Siguiente"
},
@ -103,7 +103,7 @@
"single_choice": "Elección única",
"multiple_choices": "Elección múltiple",
"expiry": "Tiempo de vida de la encuesta",
"expires_in": "La encuensta termina en {0}",
"expires_in": "La encuesta termina en {0}",
"expired": "La encuesta terminó hace {0}",
"not_enough_options": "Muy pocas opciones únicas en la encuesta"
},
@ -137,7 +137,7 @@
},
"content_warning": "Tema (opcional)",
"default": "Acabo de aterrizar en L.A.",
"direct_warning_to_all": "Esta publicación será visible para todos los usarios mencionados.",
"direct_warning_to_all": "Esta publicación será visible para todos los usuarios mencionados.",
"direct_warning_to_first_only": "Esta publicación solo será visible para los usuarios mencionados al comienzo del mensaje.",
"posting": "Publicando",
"scope_notice": {
@ -146,7 +146,7 @@
"unlisted": "Esta publicación no será visible en la Línea Temporal Pública ni en Toda La Red Conocida"
},
"scope": {
"direct": "Directo - Solo para los usuarios mencionados.",
"direct": "Directo - Solo para los usuarios mencionados",
"private": "Solo-seguidores - Solo tus seguidores leerán la publicación",
"public": "Público - Entradas visibles en las Líneas Temporales Públicas",
"unlisted": "Sin listar - Entradas no visibles en las Líneas Temporales Públicas"
@ -173,7 +173,7 @@
"password_confirmation_match": "la contraseña no coincide"
}
},
"selectable_list": {
"selectable_list": {
"select_all": "Seleccionar todo"
},
"settings": {
@ -181,17 +181,17 @@
"security": "Seguridad",
"enter_current_password_to_confirm": "Introduce la contraseña actual para confirmar tu identidad",
"mfa": {
"otp" : "OTP",
"setup_otp" : "Configurar OTP",
"wait_pre_setup_otp" : "preconfiguración OTP",
"confirm_and_enable" : "Confirmar y habilitar OTP",
"otp": "OTP",
"setup_otp": "Configurar OTP",
"wait_pre_setup_otp": "preconfiguración OTP",
"confirm_and_enable": "Confirmar y habilitar OTP",
"title": "Autentificación de dos factores",
"generate_new_recovery_codes" : "Generar códigos de recuperación nuevos",
"warning_of_generate_new_codes" : "Cuando generas nuevos códigos de recuperación, los antiguos dejarán de funcionar.",
"recovery_codes" : "Códigos de recuperación.",
"generate_new_recovery_codes": "Generar códigos de recuperación nuevos",
"warning_of_generate_new_codes": "Cuando generas nuevos códigos de recuperación, los antiguos dejarán de funcionar.",
"recovery_codes": "Códigos de recuperación.",
"waiting_a_recovery_codes": "Recibiendo códigos de respaldo",
"recovery_codes_warning" : "Anote los códigos o guárdelos en un lugar seguro, de lo contrario no los volverá a ver. Si pierde el acceso a su aplicación 2FA y los códigos de recuperación, su cuenta quedará bloqueada.",
"authentication_methods" : "Métodos de autentificación",
"recovery_codes_warning": "Anote los códigos o guárdelos en un lugar seguro, de lo contrario no los volverá a ver. Si pierde el acceso a su aplicación 2FA y los códigos de recuperación, su cuenta quedará bloqueada.",
"authentication_methods": "Métodos de autentificación",
"scan": {
"title": "Escanear",
"desc": "Usando su aplicación de dos factores, escanee este código QR o ingrese la clave de texto:",
@ -210,7 +210,7 @@
"background": "Fondo",
"bio": "Biografía",
"block_export": "Exportar usuarios bloqueados",
"block_export_button": "Exporta la lista de tus usarios bloqueados a un archivo csv",
"block_export_button": "Exporta la lista de tus usuarios bloqueados a un archivo csv",
"block_import": "Importar usuarios bloqueados",
"block_import_error": "Error importando la lista de usuarios bloqueados",
"blocks_imported": "¡Lista de usuarios bloqueados importada! El procesado puede tardar un poco.",
@ -222,7 +222,7 @@
"cRed": "Rojo (Cancelar)",
"change_password": "Cambiar contraseña",
"change_password_error": "Hubo un problema cambiando la contraseña.",
"changed_password": "Contraseña cambiada correctamente!",
"changed_password": "¡Contraseña cambiada correctamente!",
"collapse_subject": "Colapsar entradas con tema",
"composing": "Redactando",
"confirm_new_password": "Confirmar la nueva contraseña",
@ -286,7 +286,7 @@
"notification_visibility_repeats": "Repeticiones (Repeats)",
"no_rich_text_description": "Eliminar el formato de texto enriquecido de todas las entradas",
"no_blocks": "No hay usuarios bloqueados",
"no_mutes": "No hay usuarios sinlenciados",
"no_mutes": "No hay usuarios silenciados",
"hide_follows_description": "No mostrar a quién sigo",
"hide_followers_description": "No mostrar quién me sigue",
"hide_follows_count_description": "No mostrar el número de cuentas que sigo",
@ -305,7 +305,7 @@
"profile_background": "Fondo del Perfil",
"profile_banner": "Cabecera del Perfil",
"profile_tab": "Perfil",
"radii_help": "Estable el redondeo de las esquinas de la interfaz (en píxeles)",
"radii_help": "Establezca el redondeo de las esquinas de la interfaz (en píxeles)",
"replies_in_timeline": "Réplicas en la línea temporal",
"reply_link_preview": "Activar la previsualización del enlace de responder al pasar el ratón por encima",
"reply_visibility_all": "Mostrar todas las réplicas",
@ -337,7 +337,7 @@
"theme_help_v2_1": "También puede invalidar los colores y la opacidad de ciertos componentes si activa la casilla de verificación. Use el botón \"Borrar todo\" para deshacer los cambios.",
"theme_help_v2_2": "Los iconos debajo de algunas entradas son indicadores de contraste de fondo/texto, desplace el ratón por encima para obtener información más detallada. Tenga en cuenta que cuando se utilizan indicadores de contraste de transparencia se muestra el peor caso posible.",
"tooltipRadius": "Información/alertas",
"upload_a_photo": "Subir una foto",
"upload_a_photo": "Subir una foto",
"user_settings": "Ajustes del Usuario",
"values": {
"false": "no",
@ -583,7 +583,7 @@
"profile_does_not_exist": "Lo sentimos, este perfil no existe.",
"profile_loading_error": "Lo sentimos, hubo un error al cargar este perfil."
},
"user_reporting": {
"user_reporting": {
"title": "Reportando a {0}",
"add_comment_description": "El informe será enviado a los moderadores de su instancia. Puedes proporcionar una explicación de por qué estás reportando esta cuenta a continuación:",
"additional_comments": "Comentarios adicionales",
@ -603,7 +603,7 @@
"favorite": "Favorito",
"user_settings": "Ajustes de usuario"
},
"upload":{
"upload": {
"error": {
"base": "Subida fallida.",
"file_too_big": "Archivo demasiado grande [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]",
@ -635,4 +635,4 @@
"too_many_requests": "Has alcanzado el límite de intentos, vuelve a intentarlo más tarde.",
"password_reset_disabled": "El restablecimiento de contraseñas está deshabilitado. Póngase en contacto con el administrador de su instancia."
}
}
}

View file

@ -4,7 +4,19 @@
"find_user": "Otsi kasutajaid"
},
"general": {
"submit": "Postita"
"submit": "Postita",
"verify": "Kinnita",
"confirm": "Kinnita",
"enable": "Luba",
"disable": "Keela",
"cancel": "Tühista",
"dismiss": "Olgu",
"show_less": "Kuva vähem",
"show_more": "Kuva rohkem",
"optional": "valikuline",
"generic_error": "Esines viga",
"more": "Rohkem",
"apply": "Rakenda"
},
"login": {
"login": "Logi sisse",
@ -12,29 +24,95 @@
"password": "Parool",
"placeholder": "nt lain",
"register": "Registreeru",
"username": "Kasutajanimi"
"username": "Kasutajanimi",
"heading": {
"recovery": "Kaheastmelise autentimise taaste",
"totp": "Kaheastmeline autentimine"
},
"recovery_code": "Taastekood",
"enter_two_factor_code": "Sisesta kaheastmelise autentimise kood",
"enter_recovery_code": "Sisesta taastekood",
"authentication_code": "Autentimiskood",
"hint": "Logi sisse, et liituda vestlusega",
"description": "Logi sisse OAuthiga"
},
"nav": {
"mentions": "Mainimised",
"public_tl": "Avalik Ajajoon",
"timeline": "Ajajoon",
"twkn": "Kogu Teadaolev Võrgustik"
"twkn": "Kogu Teadaolev Võrgustik",
"preferences": "Eelistused",
"who_to_follow": "Keda jälgida",
"search": "Otsing",
"user_search": "Kasutajaotsing",
"dms": "Privaatsõnumid",
"interactions": "Interaktsioonid",
"friend_requests": "Jägimistaotlused",
"chat": "Kohalik vestlus",
"back": "Tagasi",
"administration": "Administreerimine",
"about": "Meist"
},
"notifications": {
"followed_you": "alustas sinu jälgimist",
"notifications": "Teavitused",
"read": "Loe!"
"notifications": "Teated",
"read": "Loe!",
"reacted_with": "reageeris {0}",
"migrated_to": "kolis",
"no_more_notifications": "Rohkem teateid ei ole",
"repeated_you": "taaspostitas su staatuse",
"load_older": "Laadi vanemad teated",
"follow_request": "soovib Teid jälgida",
"favorited_you": "lisas su staatuse lemmikuks",
"broken_favorite": "Tundmatu staatus, otsin…"
},
"post_status": {
"default": "Just sõitsin elektrirongiga Tallinnast Pääskülla.",
"posting": "Postitan"
"posting": "Postitan",
"scope": {
"unlisted": "Peidetud - Ära postita avalikele ajajoontele",
"public": "Avalil - Postita avalikele ajajoontele",
"private": "Jälgijatele - Postita ainult jälgijatele",
"direct": "Privaatne - Postita ainult mainitud kasutajatele"
},
"scope_notice": {
"unlisted": "See postitus ei ole nähtav avalikul ega kogu võrgu ajajoonel",
"private": "See postitus on nähtav ainult Teie jälgijatele",
"public": "See postitus on nähtav kõigile"
},
"direct_warning_to_first_only": "See postitus on nähtav ainult kirja alguses mainitud kasutajatele.",
"direct_warning_to_all": "See postitus on nähtav kõikidele mainitud kasutajatele.",
"content_warning": "Pealkiri (valikuline)",
"content_type": {
"text/bbcode": "BBCode",
"text/markdown": "Markdown",
"text/html": "HTML",
"text/plain": "Lihttekst"
},
"attachments_sensitive": "Märgi manused sensitiivseks",
"account_not_locked_warning_link": "lukus",
"account_not_locked_warning": "Teie konto ei ole {0}. Kõik võivad Teid jälgida, et näha Teie ainult-jälgijatele postitusi.",
"new_status": "Postita uus staatus"
},
"registration": {
"bio": "Bio",
"email": "E-post",
"fullname": "Kuvatav nimi",
"password_confirm": "Parooli kinnitamine",
"registration": "Registreerimine"
"registration": "Registreerimine",
"validations": {
"password_confirmation_match": "peaks olema sama kui salasõna",
"password_confirmation_required": "ei saa jätta tühjaks",
"password_required": "ei saa jätta tühjaks",
"email_required": "ei saa jätta tühjaks",
"fullname_required": "ei saa jätta tühjaks",
"username_required": "ei saa jätta tühjaks"
},
"fullname_placeholder": "Näiteks Lain Iwakura",
"username_placeholder": "Näiteks lain",
"new_captcha": "Vajuta pildile, et saada uus captcha",
"captcha": "CAPTCHA",
"token": "Kutse võti"
},
"settings": {
"attachments": "Manused",
@ -44,7 +122,7 @@
"current_avatar": "Sinu praegune profiilipilt",
"current_profile_banner": "Praegune profiilibänner",
"filtering": "Sisu filtreerimine",
"filtering_explanation": "Kõiki staatuseid, mis sisaldavad neid sõnu, ei kuvata. Üks sõna reale.",
"filtering_explanation": "Kõiki staatuseid, mis sisaldavad neid sõnu, ei kuvata. Üks sõna reale",
"hide_attachments_in_convo": "Peida manused vastlustes",
"hide_attachments_in_tl": "Peida manused ajajoonel",
"name": "Nimi",
@ -58,7 +136,201 @@
"set_new_profile_banner": "Vali uus profiilibänner",
"settings": "Sätted",
"theme": "Teema",
"user_settings": "Kasutaja sätted"
"user_settings": "Kasutaja sätted",
"subject_line_noop": "Ära kopeeri",
"subject_line_mastodon": "Nagu mastodon: kopeeri nagu on",
"subject_line_email": "Nagu e-post: \"vs: pealkiri\"",
"subject_line_behavior": "Kopeeri pealkiri vastamisel",
"subject_input_always_show": "Alati kuva pealkirja välja",
"minimal_scopes_mode": "Peida postituse nähtavussätted",
"scope_copy": "Kopeeri nähtavussätted vastamisel (Privaatsed on alati kopeeritud)",
"security_tab": "Turvalisus",
"search_user_to_mute": "Otsi, keda soovid vaigistada",
"search_user_to_block": "Otsi, keda soovid blokeerida",
"saving_ok": "Sätted salvestatud",
"saving_err": "Sätete salvestamine ebaõnnestus",
"autohide_floating_post_button": "Automaatselt peida uue postituse nupp (mobiilil)",
"reply_visibility_self": "Näita ainult vastuseid, mis on suunatud mulle",
"reply_visibility_following": "Näita ainult vastuseid, mis on suunatud mulle või kasutajatele, keda jälgin",
"reply_visibility_all": "Näita kõiki vastuseid",
"replies_in_timeline": "Vastused ajajoonel",
"radii_help": "Liidese ümardamine (pikslites)",
"profile_tab": "Profiil",
"presets": "Salvestatud sätted",
"pause_on_unfocused": "Peata reaalajas voog kui leht pole fookuses",
"panelRadius": "Paneelid",
"revoke_token": "Keela",
"valid_until": "Kehtiv kuni",
"refresh_token": "Värskendustoken",
"token": "Token",
"oauth_tokens": "OAuth tokenid",
"show_moderator_badge": "Näita Moderaator silti mu profiilil",
"show_admin_badge": "Näita Admin silti mu profiilil",
"hide_followers_count_description": "Ära näita minu jälgijate arvu",
"hide_follows_count_description": "Ära näita minu jälgimiste arvu",
"hide_followers_description": "Ära näita minu jälgijaid",
"hide_follows_description": "Ära näita minu jälgimisi",
"no_mutes": "Vaigistusi pole",
"no_blocks": "Blokeeringuid pole",
"no_rich_text_description": "Muuda kõik postitused lihttekstiks",
"notification_visibility_emoji_reactions": "Reaktsioonid",
"notification_visibility_moves": "Kasutaja kolimised",
"notification_visibility_repeats": "Taaspostitused",
"notification_visibility_mentions": "Mainimised",
"notification_visibility_likes": "Lemmikud",
"notification_visibility_follows": "Jälgimised",
"notification_visibility": "Milliseid teateid kuvatakse",
"new_password": "Uus salasõna",
"new_email": "Uus e-post",
"use_contain_fit": "Näita eelvaadetes täis suuruses pilte",
"play_videos_in_modal": "Näita videoid eraldi raamis",
"mutes_tab": "Vaigistused",
"loop_video_silent_only": "Loop videod, millel pole heli (nt. Mastodoni \"gifid\")",
"loop_video": "Loop videod",
"lock_account_description": "Piira oma konto ainult lubatud jälgijatele",
"links": "Lingid",
"limited_availability": "Pole Teie veebilehitsejas saadaval",
"invalid_theme_imported": "Valitud fail ei ole Pleroma kujundus. Kujundusele muudatusi ei tehtud.",
"interfaceLanguage": "Liidese keel",
"interface": "Liides",
"instance_default_simple": "(vaikimisi)",
"instance_default": "(vaikimisi: {value})",
"checkboxRadius": "Märkeruudud",
"inputRadius": "Sisestuskastid",
"import_theme": "Lae sätted",
"import_followers_from_a_csv_file": "Impordi jälgimised csv failist",
"import_blocks_from_a_csv_file": "Impordi blokeeringud csv failist",
"hide_filtered_statuses": "Peida filtreeritud staatused",
"hide_user_stats": "Peida kasutaja statistika (nt. jälgijate arv)",
"hide_post_stats": "Peida postituse statistika (nt. lemmikute arv)",
"use_one_click_nsfw": "Ava NSFW manused ühe klikiga",
"preload_images": "Piltide eellaadimine",
"hide_isp": "Peida instantsipõhine paneel",
"max_thumbnails": "Maksimaalne lubatud eelvaadete arv postituste kohta",
"hide_muted_posts": "Peida vaigistatud kasutajate postitused",
"general": "Üldine",
"foreground": "Esiplaan",
"accent": "Rõhk",
"follows_imported": "Jälgimised imporditud! Nende töötlemine võtab natuke aega.",
"follow_import_error": "Jälgimiste importimisel tekkis viga",
"follow_import": "Impordi jälgimised",
"follow_export_button": "Ekspordi oma jälgimised csv failiks",
"follow_export": "Ekspordi jälgimised",
"export_theme": "Salvesta sätted",
"emoji_reactions_on_timeline": "Näita reaktsioone ajajoonel",
"pad_emoji": "Lisa emotikonidele tühikud ette ja järgi neid menüüst valides",
"avatar_size_instruction": "Profiilipildi soovitatud minimaalne suurus on 150x150 pikslit.",
"domain_mutes": "Domeenid",
"discoverable": "Luba selle konto ilmumine otsingutulemustes ning muudes teenustes",
"delete_account_instructions": "Konto kustutamise kinnitamiseks sisestage oma salasõna.",
"delete_account_error": "Teie konto kustutamisel tekkis viga. Kui see jätkub, palun võtke kontakti administraatoriga.",
"delete_account_description": "Jäädavalt kustuta oma andmed ja konto.",
"delete_account": "Kustuta konto",
"default_vis": "Vaikimisi nähtavus",
"data_import_export_tab": "Andmete import / eksport",
"current_password": "Praegune salasõna",
"confirm_new_password": "Kinnita uus salasõna",
"composing": "Koostamine",
"collapse_subject": "Peida postituste pealkirjad",
"changed_password": "Salasõna edukalt muudetud!",
"change_password_error": "Esines viga salasõna muutmisel.",
"change_password": "Muuda salasõna",
"changed_email": "E-post edukalt muudetud!",
"change_email_error": "Esines viga e-posti muutmisel.",
"change_email": "Muuda e-posti",
"cRed": "Punane (Tühista)",
"cOrange": "Oranž (Lisa lemmikuks)",
"cGreen": "Roheline (Taaspostita)",
"cBlue": "Sinine (Vasta, jälgi)",
"btnRadius": "Nupud",
"blocks_tab": "Blokeeringud",
"blocks_imported": "Blokeeringud imporditud! Nende töötlemine võtab natuke aega.",
"block_import_error": "Blokeeringute importimisel esines viga",
"block_import": "Blokeeringute import",
"block_export_button": "Ekspordi oma blokeeringud csv failiks",
"block_export": "Blokeeringute eksport",
"background": "Taust",
"avatarRadius": "Profiilipildid",
"avatarAltRadius": "Profiilipildid (Teated)",
"attachmentRadius": "Manused",
"allow_following_move": "Luba automaatjälgimine kui jälgitav konto kolib",
"mfa": {
"verify": {
"desc": "Et lubada kaheastmelist autentimist, sisestage kood oma äpist:"
},
"scan": {
"desc": "Kasutades oma kaheastmelise autentimise äppi, skännige see QR kood või sisestage tekstiline võti:",
"secret_code": "Võti",
"title": "Skänni"
},
"authentication_methods": "Autentimismeetodid",
"recovery_codes_warning": "Kirjutage need koodid üles ning hoidke need kindlas kohas. Kui Te kaotate ligipääsu oma kaheastmelise autentimise äppile ning nendele koodidele, ei ole Teil võimalik oma kontosse sisse logida.",
"waiting_a_recovery_codes": "Laen taastekoode…",
"recovery_codes": "Taastekoodid.",
"warning_of_generate_new_codes": "Kui Te loote uued taastekoodid, Teie vanad koodid ei tööta enam.",
"generate_new_recovery_codes": "Loo uued taastekoodid",
"title": "Kaheastmeline autentimine",
"confirm_and_enable": "Kinnita & luba OTP",
"wait_pre_setup_otp": "sean üles OTP",
"setup_otp": "Sea üles OTP",
"otp": "OTP"
},
"enter_current_password_to_confirm": "Sisetage isiku tõestamiseks oma salasõna",
"security": "Turvalisus",
"app_name": "Rakenduse nimi",
"style": {
"switcher": {
"help": {
"snapshot_present": "Kujunduse eelvaade on laetud, nii et kõik väärtused on üle kirjutatud. Te saate laadida ka kujunduse päris sisu.",
"older_version_imported": "Teie imporditud fail oli loodud vanemas versioonis.",
"future_version_imported": "Teie imporditud fail oli loodud uuemas versioonis.",
"v2_imported": "Teie imporditud fail oli vanema versiooni jaoks. Me üritame hoida ühilduvust, kuid ikkagi võib esineda erinevusi.",
"upgraded_from_v2": "PleromaFE-d uuendati, teie kujundus võib välja näha natuke erinev, kui mäletate."
},
"use_source": "Uus versioon",
"use_snapshot": "Vana versioon",
"keep_as_is": "Jäta nii, nagu on",
"load_theme": "Lae kujundus",
"clear_opacity": "Tühista läbipaistvus",
"clear_all": "Tühista kõik",
"reset": "Taasta algne",
"keep_fonts": "Jäta fondid",
"keep_roundness": "Jäta ümarus",
"keep_opacity": "Jäta läbipaistvus",
"keep_shadows": "Jäta varjud",
"keep_color": "Jäta värvid"
}
},
"enable_web_push_notifications": "Luba veebipõhised push-teated",
"notification_blocks": "Kasutaja blokeerimisel ei tule neilt enam teateid ning nendele teilt ka mitte.",
"notification_setting_privacy_option": "Peida saatja ning sisu push-teadetelt",
"notification_setting": "Saa teateid nendelt:",
"notifications": "Teated",
"notification_mutes": "Kui soovid mõnelt kasutajalt mitte teateid saada, kasuta vaigistust.",
"notification_setting_privacy": "Privaatsus",
"notification_setting_non_followers": "Kasutajatelt, kes sind ei jälgi",
"notification_setting_followers": "Kasutajatelt, kes jälgivad sind",
"notification_setting_non_follows": "Kasutajatelt, keda sa ei jälgi",
"notification_setting_follows": "Kasutajatelt, keda jälgid",
"notification_setting_filters": "Filtrid",
"greentext": "Meemi nooled",
"fun": "Naljad",
"values": {
"true": "jah",
"false": "ei"
},
"upload_a_photo": "Lae üles foto",
"type_domains_to_mute": "Trüki siia domeene, mida vaigistada",
"tooltipRadius": "Vihjed/hoiatused",
"theme_help_v2_1": "Te saate ka mõndade komponentide värvust ning läbipaistvust üle kirjutada vajutades ruudule. Kasuta \"Tühista kõik\" nuppu, et need tühistada.",
"theme_help": "Kasuta hex värvikoode (#rrggbb) oma kujunduse isikupärastamiseks.",
"text": "Tekst",
"useStreamingApiWarning": "(Pole soovituslik, eksperimentaalne, on teada, et jätab postitusi vahele)",
"useStreamingApi": "Saa postitusi ning teateid reaalajas",
"user_mutes": "Kasutajad",
"streaming": "Luba uute postituste automaatvoog kui oled lehekülje alguses",
"stop_gifs": "Mängi GIFid hiirega ületades",
"post_status_content_type": "Postituse sisutüüp"
},
"timeline": {
"conversation": "Vestlus",
@ -79,5 +351,111 @@
"muted": "Vaigistatud",
"per_day": "päevas",
"statuses": "Staatuseid"
},
"about": {
"mrf": {
"mrf_policies_desc": "MRF poliitikad mõjutavad selle instansi föderatsiooni käitumist. Järgmised poliitikad on lubatud:",
"simple": {
"media_nsfw_desc": "See instants määrab nendest instantsidest postituste meedia sensitiivseks:",
"media_nsfw": "Meedia määratakse sensitiivseks",
"media_removal_desc": "See instants eemaldab meedia postitustelt nendest instantsidest:",
"media_removal": "Meedia eemaldamine",
"ftl_removal_desc": "See instants eemaldab postitused nendelt instantsidest \"Kogu teatud võrgu\" ajajoonelt:",
"ftl_removal": "\"Kogu teatud võrgu\" ajajoonelt eemaldamine",
"quarantine_desc": "See instants saadab ainult avalikke postitusi järgmistele instantsidele:",
"quarantine": "Karantiini",
"reject_desc": "See instants ei luba sõnumeid nendest instantsidest:",
"reject": "Keela",
"accept_desc": "See instants lubab sõnumeid ainult nendest instantsidest:",
"accept": "Luba",
"simple_policies": "Instansi-omased poliitikad"
},
"mrf_policies": "Lubatud MRF poliitikad",
"keyword": {
"is_replaced_by": "→",
"replace": "Vaheta",
"reject": "Lükka tagasi",
"ftl_removal": "\"Kogu teatud võrgu\" ajajoonelt eemaldamine",
"keyword_policies": "Võtmesõna poliitikad"
},
"federation": "Föderatsioon"
},
"staff": "Personal"
},
"selectable_list": {
"select_all": "Vali kõik"
},
"remote_user_resolver": {
"error": "Ei leitud.",
"searching_for": "Otsin",
"remote_user_resolver": "Kaugkasutaja leidja"
},
"interactions": {
"load_older": "Laadi vanemad interaktsioonid",
"moves": "Kasutaja kolimised",
"follows": "Uued jälgimised",
"favs_repeats": "Taaspostitused ja lemmikud"
},
"emoji": {
"load_all": "Laen kõik {emojiAmount} emotikoni",
"load_all_hint": "Laadisin esimesed {saneAmount} emotikoni, kõike laadides võib esineda probleeme jõudlusega.",
"unicode": "Unicode emotikonid",
"custom": "Kohandatud emotikonid",
"add_emoji": "Lisa emotikon",
"search_emoji": "Otsi emotikone",
"keep_open": "Hoia valija lahti",
"emoji": "Emotikonid",
"stickers": "Kleepsud"
},
"polls": {
"not_enough_options": "Liiga vähe unikaalseid valikuid hääletuses",
"expired": "Hääletus lõppes {0} tagasi",
"expires_in": "Hääletus lõppeb {0}",
"expiry": "Hääletuse vanus",
"multiple_choices": "Mitu vastust",
"single_choice": "Üks vastus",
"type": "Hääletuse tüüp",
"vote": "Hääleta",
"votes": "häält",
"option": "Valik",
"add_option": "Lisa valik",
"add_poll": "Lisa küsitlus"
},
"media_modal": {
"next": "Järgmine",
"previous": "Eelmine"
},
"importer": {
"error": "Faili importimisel tekkis viga.",
"success": "Import õnnestus.",
"submit": "Esita"
},
"image_cropper": {
"cancel": "Tühista",
"save_without_cropping": "Salvesta muudatusteta",
"save": "Salvesta",
"crop_picture": "Modifitseeri pilti"
},
"features_panel": {
"who_to_follow": "Keda jälgida",
"title": "Featuurid",
"text_limit": "Tekstilimiit",
"scope_options": "Ulatuse valikud",
"media_proxy": "Meedia proksi",
"gopher": "Gopher",
"chat": "Vestlus"
},
"exporter": {
"processing": "Töötlemine, Teilt küsitakse varsti faili allalaadimist",
"export": "Ekspordi"
},
"domain_mute_card": {
"unmute_progress": "Eemaldan vaigistuse…",
"unmute": "Ära vaigista",
"mute_progress": "Vaigistan…",
"mute": "Vaigista"
},
"chat": {
"title": "Vestlus"
}
}

View file

@ -19,7 +19,16 @@
"apply": "Aseta",
"submit": "Lähetä",
"more": "Lisää",
"generic_error": "Virhe tapahtui"
"generic_error": "Virhe tapahtui",
"optional": "valinnainen",
"show_more": "Näytä lisää",
"show_less": "Näytä vähemmän",
"dismiss": "Sulje",
"cancel": "Peruuta",
"disable": "Poista käytöstä",
"confirm": "Hyväksy",
"verify": "Varmenna",
"enable": "Ota käyttöön"
},
"login": {
"login": "Kirjaudu sisään",
@ -28,7 +37,16 @@
"password": "Salasana",
"placeholder": "esim. Seppo",
"register": "Rekisteröidy",
"username": "Käyttäjänimi"
"username": "Käyttäjänimi",
"hint": "Kirjaudu sisään liittyäksesi keskusteluun",
"authentication_code": "Todennuskoodi",
"enter_recovery_code": "Syötä palautuskoodi",
"recovery_code": "Palautuskoodi",
"heading": {
"totp": "Monivaihetodennus",
"recovery": "Monivaihepalautus"
},
"enter_two_factor_code": "Syötä monivaihetodennuskoodi"
},
"nav": {
"about": "Tietoja",
@ -43,7 +61,9 @@
"twkn": "Koko Tunnettu Verkosto",
"user_search": "Käyttäjähaku",
"who_to_follow": "Seurausehdotukset",
"preferences": "Asetukset"
"preferences": "Asetukset",
"administration": "Ylläpito",
"search": "Haku"
},
"notifications": {
"broken_favorite": "Viestiä ei löydetty...",
@ -54,7 +74,9 @@
"read": "Lue!",
"repeated_you": "toisti viestisi",
"no_more_notifications": "Ei enempää ilmoituksia",
"reacted_with": "lisäsi reaktion {0}"
"reacted_with": "lisäsi reaktion {0}",
"migrated_to": "siirtyi sivulle",
"follow_request": "haluaa seurata sinua"
},
"polls": {
"add_poll": "Lisää äänestys",
@ -68,12 +90,14 @@
"expiry": "Äänestyksen kesto",
"expires_in": "Päättyy {0} päästä",
"expired": "Päättyi {0} sitten",
"not_enough_option": "Liian vähän uniikkeja vaihtoehtoja äänestyksessä"
"not_enough_option": "Liian vähän uniikkeja vaihtoehtoja äänestyksessä",
"not_enough_options": "Liian vähän ainutkertaisia vaihtoehtoja"
},
"interactions": {
"favs_repeats": "Toistot ja tykkäykset",
"follows": "Uudet seuraukset",
"load_older": "Lataa vanhempia interaktioita"
"load_older": "Lataa vanhempia interaktioita",
"moves": "Käyttäjien siirtymiset"
},
"post_status": {
"new_status": "Uusi viesti",
@ -81,7 +105,10 @@
"account_not_locked_warning_link": "lukittu",
"attachments_sensitive": "Merkkaa liitteet arkaluonteisiksi",
"content_type": {
"text/plain": "Tavallinen teksti"
"text/plain": "Tavallinen teksti",
"text/html": "HTML",
"text/markdown": "Markdown",
"text/bbcode": "BBCode"
},
"content_warning": "Aihe (valinnainen)",
"default": "Tulin juuri saunasta.",
@ -92,6 +119,13 @@
"private": "Vain-seuraajille - Näkyy vain seuraajillesi",
"public": "Julkinen - Näkyy julkisilla aikajanoilla",
"unlisted": "Listaamaton - Ei näy julkisilla aikajanoilla"
},
"direct_warning_to_all": "Tämä viesti näkyy vain viestissä mainituille käyttäjille.",
"direct_warning_to_first_only": "Tämä viesti näkyy vain viestin alussa mainituille käyttäjille.",
"scope_notice": {
"public": "Tämä viesti näkyy kaikille",
"private": "Tämä viesti näkyy vain sinun seuraajillesi",
"unlisted": "Tämä viesti ei näy Julkisella Aikajanalla tai Koko Tunnettu Verkosto -aikajanalla"
}
},
"registration": {
@ -110,7 +144,10 @@
"password_required": "ei voi olla tyhjä",
"password_confirmation_required": "ei voi olla tyhjä",
"password_confirmation_match": "pitää vastata salasanaa"
}
},
"username_placeholder": "esim. peke",
"fullname_placeholder": "esim. Pekka Postaaja",
"bio_placeholder": "esim.\nHei, olen Pekka.\nOlen esimerkkikäyttäjä tässä verkostossa."
},
"settings": {
"attachmentRadius": "Liitteet",
@ -151,7 +188,7 @@
"follow_import": "Seurausten tuonti",
"follow_import_error": "Virhe tuodessa seuraksia",
"follows_imported": "Seuraukset tuotu! Niiden käsittely vie hetken.",
"foreground": "Korostus",
"foreground": "Etuala",
"general": "Yleinen",
"hide_attachments_in_convo": "Piilota liitteet keskusteluissa",
"hide_attachments_in_tl": "Piilota liitteet aikajanalla",
@ -186,14 +223,14 @@
"notification_visibility_mentions": "Maininnat",
"notification_visibility_repeats": "Toistot",
"notification_visibility_emoji_reactions": "Reaktiot",
"no_rich_text_description": "Älä näytä tekstin muotoilua.",
"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",
"revoke_token": "Peruuta",
"panelRadius": "Ruudut",
"pause_on_unfocused": "Pysäytä automaattinen viestien näyttö välilehden ollessa pois fokuksesta",
"presets": "Valmiit teemat",
@ -231,6 +268,228 @@
"values": {
"false": "pois päältä",
"true": "päällä"
},
"hide_follows_description": "Älä näytä ketä seuraan",
"show_moderator_badge": "Näytä Moderaattori-merkki profiilissani",
"useStreamingApi": "Vastaanota viestiejä ja ilmoituksia reaaliajassa",
"notification_setting_filters": "Suodattimet",
"notification_setting": "Vastaanota ilmoituksia seuraavista:",
"notification_setting_privacy_option": "Piilota lähettäjä ja sisältö sovelluksen ulkopuolisista ilmoituksista",
"enable_web_push_notifications": "Ota käyttöön sovelluksen ulkopuoliset ilmoitukset",
"app_name": "Sovelluksen nimi",
"security": "Turvallisuus",
"mfa": {
"otp": "OTP",
"setup_otp": "OTP-asetukset",
"wait_pre_setup_otp": "esiasetetaan OTP:ta",
"confirm_and_enable": "Hyväksy ja käytä OTP",
"title": "Monivaihetodennus",
"generate_new_recovery_codes": "Luo uudet palautuskoodit",
"authentication_methods": "Todennus",
"warning_of_generate_new_codes": "Luodessasi uudet palautuskoodit, vanhat koodisi lakkaavat toimimasta.",
"recovery_codes": "Palautuskoodit.",
"waiting_a_recovery_codes": "Odotetaan palautuskoodeja...",
"recovery_codes_warning": "Kirjoita koodit ylös tai tallenna ne turvallisesti, muuten et näe niitä uudestaan. Jos et voi käyttää monivaihetodennusta ja sinulla ei ole palautuskoodeja, et voi enää kirjautua sisään tilillesi.",
"scan": {
"title": "Skannaa",
"secret_code": "Avain",
"desc": "Käytä monivaihetodennus-sovellusta skannakksesi tämän QR-kooding, tai syötä avain:"
},
"verify": {
"desc": "Kytkeäksesi päälle monivaihetodennuksen, syötä koodi monivaihetodennussovellksesta:"
}
},
"allow_following_move": "Salli automaattinen seuraaminen kun käyttäjä siirtää tilinsä",
"block_export": "Estojen vienti",
"block_export_button": "Vie estosi CSV-tiedostoon",
"block_import": "Estojen tuonti",
"block_import_error": "Virhe tuodessa estoja",
"blocks_imported": "Estot tuotu! Käsittely vie hetken.",
"blocks_tab": "Estot",
"change_email": "Vaihda sähköpostiosoite",
"change_email_error": "Virhe vaihtaessa sähköpostiosoitetta.",
"changed_email": "Sähköpostiosoite vaihdettu!",
"domain_mutes": "Sivut",
"avatar_size_instruction": "Suositeltu vähimmäiskoko profiilikuville on 150x150 pikseliä.",
"accent": "Korostus",
"hide_muted_posts": "Piilota mykistettyjen käyttäjien viestit",
"hide_filtered_statuses": "Piilota mykistetyt viestit",
"import_blocks_from_a_csv_file": "Tuo estot CSV-tiedostosta",
"no_blocks": "Ei estoja",
"no_mutes": "Ei mykistyksiä",
"notification_visibility_moves": "Käyttäjien siirtymiset",
"hide_followers_description": "Älä näytä ketkä seuraavat minua",
"hide_follows_count_description": "Älä näytä seurauksien määrää",
"hide_followers_count_description": "Älä näytä seuraajien määrää",
"show_admin_badge": "Näytä Ylläpitäjä-merkki proofilissani",
"autohide_floating_post_button": "Piilota Uusi Viesti -nappi automaattisesti (mobiili)",
"search_user_to_block": "Hae estettäviä käyttäjiä",
"search_user_to_mute": "Hae mykistettäviä käyttäjiä",
"minimal_scopes_mode": "Yksinkertaista näkyvyydenrajauksen vaihtoehdot",
"post_status_content_type": "Uuden viestin sisällön muoto",
"user_mutes": "Käyttäjät",
"useStreamingApiWarning": "(Kokeellinen)",
"type_domains_to_mute": "Syötä mykistettäviä sivustoja",
"upload_a_photo": "Lataa kuva",
"fun": "Hupi",
"greentext": "Meeminuolet",
"notifications": "Ilmoitukset",
"style": {
"switcher": {
"save_load_hint": "\"Säilytä\" asetukset säilyttävät tällä hetkellä asetetut asetukset valittaessa tai ladatessa teemaa, se myös tallentaa kyseiset asetukset viedessä teemaa. Kun kaikki laatikot ovat tyhjänä, viety teema tallentaa kaiken.",
"help": {
"older_version_imported": "Tuomasi tiedosto on luotu vanhemmalla versiolla.",
"fe_upgraded": "PleromaFE:n teemaus päivitetty versiopäivityksen yhteydessä.",
"migration_snapshot_ok": "Varmuuden vuoksi teeman kaappaus ladattu. Voit koittaa ladata teeman sisällön.",
"migration_napshot_gone": "Jostain syystä teeman kaappaus puuttuu, kaikki asiat eivät välttämättä näytä oikealta.",
"snapshot_source_mismatch": "Versiot eivät täsmää: todennäköisesti versio vaihdettu vanhempaan ja päivitetty uudestaan, jos vaihdoit teemaa vanhalla versiolla, sinun tulisi käyttää vanhaa versiota, muutoin uutta.",
"upgraded_from_v2": "PleromaFE on päivitetty, teemasi saattaa näyttää erilaiselta kuin muistat.",
"v2_imported": "Tuomasi tiedosto on luotu vanhemmalla versiolla. Yhteensopivuus ei välttämättä ole täydellinen.",
"future_version_imported": "Tuomasi tiedosto on luotu uudemmalla versiolla.",
"snapshot_present": "Teeman kaappaus ladattu, joten kaikki arvot ovat ylikirjoitettu. Voit sen sijaan ladata teeman sisällön.",
"snapshot_missing": "Teeman kaappausta ei tiedostossa, joten se voi näyttää erilaiselta kuin suunniteltu.",
"fe_downgraded": "PleromaFE:n versio vaihtunut vanhempaan."
},
"keep_color": "Säilytä värit",
"keep_shadows": "Säilytä varjot",
"keep_opacity": "Säilytä läpinäkyvyys",
"keep_roundness": "Säilytä pyöristys",
"keep_fonts": "Säilytä fontit",
"reset": "Palauta",
"clear_all": "Tyhjennä kaikki",
"clear_opacity": "Tyhjennä läpinäkyvyys",
"load_theme": "Lataa teema",
"keep_as_is": "Pidä sellaisenaan",
"use_snapshot": "Vanha",
"use_source": "Uusi"
},
"advanced_colors": {
"selectedPost": "Valittu viesti",
"_tab_label": "Edistynyt",
"alert": "Varoituksen tausta",
"alert_error": "Virhe",
"alert_warning": "Varoitus",
"alert_neutral": "Neutraali",
"post": "Viestit/Käyttäjien kuvaukset",
"badge": "Merkin tausta",
"badge_notification": "Ilmoitus",
"panel_header": "Ruudun otsikko",
"top_bar": "Yläpalkki",
"borders": "Reunat",
"buttons": "Napit",
"inputs": "Syöttökentät",
"faint_text": "Häivytetty teksti",
"underlay": "Taustapeite",
"poll": "Äänestyksen kuvaaja",
"icons": "Ikonit",
"highlight": "Korostetut elementit",
"pressed": "Painettu",
"selectedMenu": "Valikon valinta",
"disabled": "Pois käytöstä",
"toggled": "Kytketty",
"tabs": "Välilehdet",
"popover": "Työkaluvinkit, valikot, ponnahdusviestit"
},
"common": {
"color": "Väri",
"opacity": "Läpinäkyvyys",
"contrast": {
"level": {
"aaa": "saavuttaa AAA-tason (suositeltu)",
"aa": "saavuttaa AA-tason (minimi)",
"bad": "ei saavuta mitään helppokäyttöisyyssuosituksia"
},
"hint": "Kontrastisuhde on {ratio}, se {level} {context}",
"context": {
"18pt": "suurella (18pt+) tekstillä",
"text": "tekstillä"
}
}
},
"common_colors": {
"_tab_label": "Yleinen",
"main": "Yleiset värit",
"foreground_hint": "Löydät \"Edistynyt\"-välilehdeltä tarkemmat asetukset",
"rgbo": "Ikonit, korostukset, merkit"
},
"shadows": {
"filter_hint": {
"always_drop_shadow": "Varoitus, tämä varjo käyttää aina {0} kun selain tukee sitä.",
"avatar_inset": "Huom. sisennettyjen ja ei-sisennettyjen varjojen yhdistelmät saattavat luoda ei-odotettuja lopputuloksia läpinäkyvillä profiilikuvilla.",
"drop_shadow_syntax": "{0} ei tue {1} parametria ja {2} avainsanaa.",
"spread_zero": "Varjot joiden levitys > 0 näyttävät samalta kuin se olisi nolla",
"inset_classic": "Sisennetyt varjot käyttävät {0}"
},
"components": {
"buttonPressedHover": "Nappi (painettu ja kohdistettu)",
"panel": "Ruutu",
"panelHeader": "Ruudun otsikko",
"topBar": "Yläpalkki",
"avatar": "Profiilikuva (profiilinäkymässä)",
"avatarStatus": "Profiilikuva (viestin yhtyedessä)",
"popup": "Ponnahdusviestit ja työkaluvinkit",
"button": "Nappi",
"buttonHover": "Nappi (kohdistus)",
"buttonPressed": "Nappi (painettu)",
"input": "Syöttökenttä"
},
"hintV3": "Voit käyttää {0} merkintää varjoille käyttääksesi väriä toisesta asetuksesta.",
"_tab_label": "Valo ja varjostus",
"component": "Komponentti",
"override": "Ylikirjoita",
"shadow_id": "Varjo #{value}",
"blur": "Sumennus",
"spread": "Levitys",
"inset": "Sisennys"
},
"fonts": {
"help": "Valitse fontti käyttöliittymälle. \"Oma\"-vaihtohdolle on syötettävä fontin nimi tarkalleen samana kuin se on järjestelmässäsi.",
"_tab_label": "Fontit",
"components": {
"interface": "Käyttöliittymä",
"input": "Syöttökentät",
"post": "Viestin teksti",
"postCode": "Tasavälistetty teksti viestissä"
},
"family": "Fontin nimi",
"size": "Koko (pikseleissä)",
"weight": "Painostus (paksuus)",
"custom": "Oma"
},
"preview": {
"input": "Tulin juuri saunasta.",
"header": "Esikatselu",
"content": "Sisältö",
"error": "Esimerkkivirhe",
"button": "Nappi",
"text": "Vähän lisää {0} ja {1}",
"mono": "sisältöä",
"faint_link": "manuaali",
"fine_print": "Lue meidän {0} vaikka huvin vuoksi!",
"header_faint": "Tämä on OK",
"checkbox": "Olen silmäillyt käyttöehdot",
"link": "kiva linkki"
},
"radii": {
"_tab_label": "Pyöristys"
}
},
"enter_current_password_to_confirm": "Syötä nykyinen salasanasi todentaaksesi henkilöllisyytesi",
"discoverable": "Salli tilisi näkyvyys hakukoneisiin ja muihin palveluihin",
"pad_emoji": "Välistä emojit välilyönneillä lisätessäsi niitä valitsimesta",
"mutes_tab": "Mykistykset",
"new_email": "Uusi sähköpostiosoite",
"notification_setting_follows": "Käyttäjät joita seuraat",
"notification_setting_non_follows": "Käyttäjät joita et seuraa",
"notification_setting_followers": "Käyttäjät jotka seuraavat sinua",
"notification_setting_non_followers": "Käyttäjät jotka eivät seuraa sinua",
"notification_setting_privacy": "Yksityisyys",
"notification_mutes": "Jos et halua ilmoituksia joltain käyttäjältä, käytä mykistystä.",
"notification_blocks": "Estäminen pysäyttää kaikki ilmoitukset käyttäjältä ja poistaa seurauksen.",
"version": {
"title": "Versio",
"backend_version": "Palvelimen versio",
"frontend_version": "Käyttöliittymän versio"
}
},
"time": {
@ -252,8 +511,8 @@
"months": "{0} kuukautta",
"month_short": "{0}kk",
"months_short": "{0}kk",
"now": "nyt",
"now_short": "juuri nyt",
"now": "juuri nyt",
"now_short": "nyt",
"second": "{0} sekunti",
"seconds": "{0} sekuntia",
"second_short": "{0}s",
@ -276,7 +535,8 @@
"repeated": "toisti",
"show_new": "Näytä uudet",
"up_to_date": "Ajantasalla",
"no_more_statuses": "Ei enempää viestejä"
"no_more_statuses": "Ei enempää viestejä",
"no_statuses": "Ei viestejä"
},
"status": {
"favorites": "Tykkäykset",
@ -288,8 +548,10 @@
"delete_confirm": "Haluatko varmasti postaa viestin?",
"reply_to": "Vastaus",
"replies_list": "Vastaukset:",
"mute_conversation": "Hiljennä keskustelu",
"unmute_conversation": "Poista hiljennys"
"mute_conversation": "Mykistä keskustelu",
"unmute_conversation": "Poista mykistys",
"status_unavailable": "Viesti ei saatavissa",
"copy_link": "Kopioi linkki"
},
"user_card": {
"approve": "Hyväksy",
@ -298,7 +560,7 @@
"deny": "Älä hyväksy",
"follow": "Seuraa",
"follow_sent": "Pyyntö lähetetty!",
"follow_progress": "Pyydetään...",
"follow_progress": "Pyydetään",
"follow_again": "Lähetä pyyntö uudestaan",
"follow_unfollow": "Älä seuraa",
"followees": "Seuraa",
@ -306,14 +568,50 @@
"following": "Seuraat!",
"follows_you": "Seuraa sinua!",
"its_you": "Sinun tili!",
"mute": "Hiljennä",
"muted": "Hiljennetty",
"mute": "Mykistä",
"muted": "Mykistetty",
"per_day": "päivässä",
"remote_follow": "Seuraa muualta",
"statuses": "Viestit"
"statuses": "Viestit",
"hidden": "Piilotettu",
"media": "Media",
"block_progress": "Estetään...",
"admin_menu": {
"grant_admin": "Anna Ylläpitöoikeudet",
"force_nsfw": "Merkitse kaikki viestit NSFW:nä",
"disable_any_subscription": "Estä käyttäjän seuraaminen",
"moderation": "Moderaatio",
"revoke_admin": "Poista Ylläpitöoikeudet",
"grant_moderator": "Anna Moderaattorioikeudet",
"revoke_moderator": "Poista Moderaattorioikeudet",
"activate_account": "Aktivoi tili",
"deactivate_account": "Deaktivoi tili",
"delete_account": "Poista tili",
"strip_media": "Poista media viesteistä",
"force_unlisted": "Pakota viestit listaamattomiksi",
"sandbox": "Pakota viestit vain seuraajille",
"disable_remote_subscription": "Estä seuraaminen ulkopuolisilta sivuilta",
"quarantine": "Estä käyttäjän viestin federoituminen",
"delete_user": "Poista käyttäjä",
"delete_user_confirmation": "Oletko aivan varma? Tätä ei voi kumota."
},
"favorites": "Tykkäykset",
"mention": "Mainitse",
"report": "Ilmianna",
"subscribe": "Tilaa",
"unsubscribe": "Poista tilaus",
"unblock": "Poista esto",
"unblock_progress": "Postetaan estoa...",
"unmute": "Poista mykistys",
"unmute_progress": "Poistetaan mykistystä...",
"mute_progress": "Mykistetään...",
"hide_repeats": "Piilota toistot",
"show_repeats": "Näytä toistot"
},
"user_profile": {
"timeline_title": "Käyttäjän aikajana"
"timeline_title": "Käyttäjän aikajana",
"profile_does_not_exist": "Tätä profiilia ei ole.",
"profile_loading_error": "Virhe ladatessa profiilia."
},
"who_to_follow": {
"more": "Lisää",
@ -324,9 +622,12 @@
"repeat": "Toista",
"reply": "Vastaa",
"favorite": "Tykkää",
"user_settings": "Käyttäjäasetukset"
"user_settings": "Käyttäjäasetukset",
"add_reaction": "Lisää Reaktio",
"accept_follow_request": "Hyväksy seurauspyyntö",
"reject_follow_request": "Hylkää seurauspyyntö"
},
"upload":{
"upload": {
"error": {
"base": "Lataus epäonnistui.",
"file_too_big": "Tiedosto liian suuri [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]",
@ -339,5 +640,108 @@
"GiB": "Gt",
"TiB": "Tt"
}
},
"about": {
"mrf": {
"keyword": {
"keyword_policies": "Avainsanasäännöt",
"ftl_removal": "Poistettu \"Koko Tunnettu Verkosto\" -aikajanalta",
"reject": "Hylkää",
"replace": "Korvaa",
"is_replaced_by": "→"
},
"simple": {
"accept": "Hyväksy",
"reject": "Hylkää",
"quarantine": "Karanteeni",
"ftl_removal": "Poisto \"Koko Tunnettu Verkosto\" -aikajanalta",
"media_removal": "Media-tiedostojen poisto",
"simple_policies": "Palvelinkohtaiset Säännöt",
"accept_desc": "Tämä palvelin hyväksyy viestit vain seuraavilta palvelimilta:",
"reject_desc": "Tämä palvelin ei hyväksy viestejä seuraavilta palvelimilta:",
"quarantine_desc": "Tämä palvelin lähettää vain julkisia viestejä seuraaville palvelimille:",
"ftl_removal_desc": "Tämä palvelin poistaa nämä palvelimet \"Koko Tunnettu Verkosto\"-aikajanalta:",
"media_removal_desc": "Tämä palvelin postaa mediatiedostot viesteistä seuraavilta palvelimilta:",
"media_nsfw": "Pakota Media Arkaluontoiseksi",
"media_nsfw_desc": "Tämä palvelin pakottaa mediatiedostot arkaluonteisiksi seuraavilta palvelimilta:"
},
"federation": "Federaatio",
"mrf_policies": "Aktivoidut MRF-säännöt",
"mrf_policies_desc": "MRF-säännöt muuttavat federaation toimintaa sivulla. Seuraavat säännöt ovat kytketty päälle:"
},
"staff": "Henkilökunta"
},
"domain_mute_card": {
"mute": "Mykistä",
"unmute": "Poista mykistys",
"mute_progress": "Mykistetään...",
"unmute_progress": "Poistetaan mykistyst..."
},
"exporter": {
"export": "Vie",
"processing": "Käsitellään, hetken päästä voit tallentaa tiedoston"
},
"image_cropper": {
"crop_picture": "Rajaa kuva",
"save": "Tallenna",
"save_without_cropping": "Tallenna rajaamatta",
"cancel": "Peruuta"
},
"importer": {
"submit": "Hyväksy",
"error": "Virhe tapahtui tietoja tuodessa.",
"success": "Tuonti onnistui."
},
"media_modal": {
"previous": "Edellinen",
"next": "Seuraava"
},
"emoji": {
"stickers": "Tarrat",
"emoji": "Emoji",
"keep_open": "Pidä valitsin auki",
"search_emoji": "Hae emojia",
"add_emoji": "Lisää emoji",
"custom": "Custom-emoji",
"load_all": "Ladataan kaikkia {emojiAmount} emojia",
"unicode": "Unicode-emoji",
"load_all_hint": "Ensimmäiset {saneAmount} emojia ladattu, kaikkien emojien lataaminen voi aiheuttaa hidastelua."
},
"remote_user_resolver": {
"remote_user_resolver": "Ulkopuolinen käyttäjä",
"searching_for": "Etsitään käyttäjää",
"error": "Ei löytynyt."
},
"selectable_list": {
"select_all": "Valitse kaikki"
},
"password_reset": {
"check_email": "Tarkista sähköpostisi salasanannollausta varten.",
"instruction": "Syötä sähköpostiosoite tai käyttäjänimi. Lähetämme linkin salasanan nollausta varten.",
"password_reset_disabled": "Salasanan nollaus ei käytössä. Ota yhteyttä sivun ylläpitäjään.",
"password_reset_required_but_mailer_is_disabled": "Sinun täytyy vaihtaa salasana, mutta salasanan nollaus on pois käytöstä. Ota yhteyttä sivun ylläpitäjään.",
"forgot_password": "Unohditko salasanan?",
"password_reset": "Salasanan nollaus",
"placeholder": "Sähköpostiosoite tai käyttäjänimi",
"return_home": "Palaa etusivulle",
"not_found": "Sähköpostiosoitetta tai käyttäjänimeä ei löytynyt.",
"too_many_requests": "Olet käyttänyt kaikki yritykset, yritä uudelleen myöhemmin.",
"password_reset_required": "Sinun täytyy vaihtaa salasana kirjautuaksesi."
},
"user_reporting": {
"add_comment_description": "Tämä raportti lähetetään sivun moderaattoreille. Voit antaa selityksen miksi ilmiannoit tilin:",
"title": "Ilmiannetaan {0}",
"additional_comments": "Lisäkommentit",
"forward_description": "Tämä tili on toiselta palvelimelta. Lähetä kopio ilmiannosta sinnekin?",
"forward_to": "Lähetä eteenpäin: {0}",
"submit": "Lähetä",
"generic_error": "Virhe käsitellessä pyyntöä."
},
"search": {
"people": "Käyttäjät",
"hashtags": "Aihetunnisteet",
"people_talking": "{0} käyttäjää puhuvat",
"person_talking": "{0} käyttäjä puhuu",
"no_results": "Ei tuloksia"
}
}

Some files were not shown because too many files have changed in this diff Show more