Page MenuHomePhorge

No OneTemporary

Size
293 KB
Referenced Files
None
Subscribers
None
This file is larger than 256 KB, so syntax highlighting was skipped.
diff --git a/src/boot/after_store.js b/src/boot/after_store.js
index 53ecc08319..a8e2bf35f5 100644
--- a/src/boot/after_store.js
+++ b/src/boot/after_store.js
@@ -1,180 +1,182 @@
import Vue from 'vue'
import VueRouter from 'vue-router'
import routes from './routes'
import App from '../App.vue'
const afterStoreSetup = ({ store, i18n }) => {
window.fetch('/api/statusnet/config.json')
.then((res) => res.json())
.then((data) => {
const { name, closed: registrationClosed, textlimit, uploadlimit, server, vapidPublicKey } = data.site
store.dispatch('setInstanceOption', { name: 'name', value: name })
store.dispatch('setInstanceOption', { name: 'registrationOpen', value: (registrationClosed === '0') })
store.dispatch('setInstanceOption', { name: 'textlimit', value: parseInt(textlimit) })
store.dispatch('setInstanceOption', { name: 'server', value: server })
// TODO: default values for this stuff, added if to not make it break on
// my dev config out of the box.
if (uploadlimit) {
store.dispatch('setInstanceOption', { name: 'uploadlimit', value: parseInt(uploadlimit.uploadlimit) })
store.dispatch('setInstanceOption', { name: 'avatarlimit', value: parseInt(uploadlimit.avatarlimit) })
store.dispatch('setInstanceOption', { name: 'backgroundlimit', value: parseInt(uploadlimit.backgroundlimit) })
store.dispatch('setInstanceOption', { name: 'bannerlimit', value: parseInt(uploadlimit.bannerlimit) })
}
if (vapidPublicKey) {
store.dispatch('setInstanceOption', { name: 'vapidPublicKey', value: vapidPublicKey })
}
var apiConfig = data.site.pleromafe
window.fetch('/static/config.json')
.then((res) => res.json())
.catch((err) => {
console.warn('Failed to load static/config.json, continuing without it.')
console.warn(err)
return {}
})
.then((staticConfig) => {
const overrides = window.___pleromafe_dev_overrides || {}
const env = window.___pleromafe_mode.NODE_ENV
// This takes static config and overrides properties that are present in apiConfig
let config = {}
if (overrides.staticConfigPreference && env === 'development') {
console.warn('OVERRIDING API CONFIG WITH STATIC CONFIG')
config = Object.assign({}, apiConfig, staticConfig)
} else {
config = Object.assign({}, staticConfig, apiConfig)
}
const copyInstanceOption = (name) => {
store.dispatch('setInstanceOption', {name, value: config[name]})
}
copyInstanceOption('nsfwCensorImage')
copyInstanceOption('background')
copyInstanceOption('hidePostStats')
copyInstanceOption('hideUserStats')
copyInstanceOption('hideFilteredStatuses')
copyInstanceOption('logo')
store.dispatch('setInstanceOption', {
name: 'logoMask',
value: typeof config.logoMask === 'undefined'
? true
: config.logoMask
})
store.dispatch('setInstanceOption', {
name: 'logoMargin',
value: typeof config.logoMargin === 'undefined'
? 0
: config.logoMargin
})
copyInstanceOption('redirectRootNoLogin')
copyInstanceOption('redirectRootLogin')
copyInstanceOption('showInstanceSpecificPanel')
copyInstanceOption('scopeOptionsEnabled')
copyInstanceOption('formattingOptionsEnabled')
copyInstanceOption('collapseMessageWithSubject')
copyInstanceOption('loginMethod')
copyInstanceOption('scopeCopy')
copyInstanceOption('subjectLineBehavior')
copyInstanceOption('postContentType')
copyInstanceOption('alwaysShowSubjectInput')
copyInstanceOption('noAttachmentLinks')
copyInstanceOption('showFeaturesPanel')
if ((config.chatDisabled)) {
store.dispatch('disableChat')
} else {
store.dispatch('initializeSocket')
}
return store.dispatch('setTheme', config['theme'])
})
.then(() => {
const router = new VueRouter({
mode: 'history',
routes: routes(store),
scrollBehavior: (to, _from, savedPosition) => {
if (to.matched.some(m => m.meta.dontScroll)) {
return false
}
return savedPosition || { x: 0, y: 0 }
}
})
/* eslint-disable no-new */
new Vue({
router,
store,
i18n,
el: '#app',
render: h => h(App)
})
})
})
window.fetch('/static/terms-of-service.html')
.then((res) => res.text())
.then((html) => {
store.dispatch('setInstanceOption', { name: 'tos', value: html })
})
window.fetch('/api/pleroma/emoji.json')
.then(
(res) => res.json()
.then(
(values) => {
const emoji = Object.keys(values).map((key) => {
return { shortcode: key, image_url: values[key] }
})
store.dispatch('setInstanceOption', { name: 'customEmoji', value: emoji })
store.dispatch('setInstanceOption', { name: 'pleromaBackend', value: true })
},
(failure) => {
store.dispatch('setInstanceOption', { name: 'pleromaBackend', value: false })
}
),
(error) => console.log(error)
)
window.fetch('/static/emoji.json')
.then((res) => res.json())
.then((values) => {
const emoji = Object.keys(values).map((key) => {
return { shortcode: key, image_url: false, 'utf': values[key] }
})
store.dispatch('setInstanceOption', { name: 'emoji', value: emoji })
})
window.fetch('/instance/panel.html')
.then((res) => res.text())
.then((html) => {
store.dispatch('setInstanceOption', { name: 'instanceSpecificPanelContent', value: html })
})
window.fetch('/nodeinfo/2.0.json')
.then((res) => res.json())
.then((data) => {
const metadata = data.metadata
const features = metadata.features
store.dispatch('setInstanceOption', { name: 'mediaProxyAvailable', value: features.includes('media_proxy') })
store.dispatch('setInstanceOption', { name: 'chatAvailable', value: features.includes('chat') })
store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') })
+ store.dispatch('setInstanceOption', { name: 'postFormats', value: metadata.postFormats })
+
store.dispatch('setInstanceOption', { name: 'restrictedNicknames', value: metadata.restrictedNicknames })
const suggestions = metadata.suggestions
store.dispatch('setInstanceOption', { name: 'suggestionsEnabled', value: suggestions.enabled })
store.dispatch('setInstanceOption', { name: 'suggestionsWeb', value: suggestions.web })
})
}
export default afterStoreSetup
diff --git a/src/components/attachment/attachment.vue b/src/components/attachment/attachment.vue
index 76affe2dbf..c58bebd331 100644
--- a/src/components/attachment/attachment.vue
+++ b/src/components/attachment/attachment.vue
@@ -1,239 +1,240 @@
<template>
<div v-if="usePlaceHolder" @click="openModal">
<a class="placeholder"
v-if="type !== 'html'"
target="_blank" :href="attachment.url"
>
[{{nsfw ? "NSFW/" : ""}}{{type.toUpperCase()}}]
</a>
</div>
<div
v-else class="attachment"
:class="{[type]: true, loading, 'fullwidth': fullwidth, 'nsfw-placeholder': hidden}"
v-show="!isEmpty"
>
<a class="image-attachment" v-if="hidden" :href="attachment.url" @click.prevent="toggleHidden">
<img class="nsfw" :key="nsfwImage" :src="nsfwImage" :class="{'small': isSmall}"/>
<i v-if="type === 'video'" class="play-icon icon-play-circled"></i>
</a>
<div class="hider" v-if="nsfw && hideNsfwLocal && !hidden">
<a href="#" @click.prevent="toggleHidden">Hide</a>
</div>
<a v-if="type === 'image' && (!hidden || preloadImage)"
@click="openModal"
class="image-attachment"
:class="{'hidden': hidden && preloadImage }"
:href="attachment.url" target="_blank"
:title="attachment.description"
>
<StillImage :referrerpolicy="referrerpolicy" :mimetype="attachment.mimetype" :src="attachment.large_thumb_url || attachment.url"/>
</a>
<a class="video-container"
@click="openModal"
v-if="type === 'video' && !hidden"
:class="{'small': isSmall}"
:href="allowPlay ? undefined : attachment.url"
>
<VideoAttachment class="video" :attachment="attachment" :controls="allowPlay" />
<i v-if="!allowPlay" class="play-icon icon-play-circled"></i>
</a>
<audio v-if="type === 'audio'" :src="attachment.url" controls></audio>
<div @click.prevent="linkClicked" v-if="type === 'html' && attachment.oembed" class="oembed">
<div v-if="attachment.thumb_url" class="image">
<img :src="attachment.thumb_url"/>
</div>
<div class="text">
<h1><a :href="attachment.url">{{attachment.oembed.title}}</a></h1>
<div v-html="attachment.oembed.oembedHTML"></div>
</div>
</div>
</div>
</template>
<script src="./attachment.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.attachments {
display: flex;
flex-wrap: wrap;
.attachment.media-upload-container {
flex: 0 0 auto;
max-height: 200px;
max-width: 100%;
display: flex;
video {
max-width: 100%;
}
}
.placeholder {
margin-right: 8px;
margin-bottom: 4px;
}
.nsfw-placeholder {
cursor: pointer;
&.loading {
cursor: progress;
}
}
.attachment {
position: relative;
margin-top: 0.5em;
align-self: flex-start;
line-height: 0;
border-style: solid;
border-width: 1px;
border-radius: $fallback--attachmentRadius;
border-radius: var(--attachmentRadius, $fallback--attachmentRadius);
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
overflow: hidden;
}
.non-gallery.attachment {
&.video {
flex: 1 0 40%;
}
.nsfw {
height: 260px;
}
.small {
height: 120px;
flex-grow: 0;
}
.video {
height: 260px;
display: flex;
}
video {
max-height: 100%;
object-fit: contain;
}
}
.fullwidth {
flex-basis: 100%;
}
// fixes small gap below video
&.video {
line-height: 0;
}
.video-container {
display: flex;
max-height: 100%;
}
.video {
width: 100%;
}
.play-icon {
position: absolute;
font-size: 64px;
top: calc(50% - 32px);
left: calc(50% - 32px);
color: rgba(255, 255, 255, 0.75);
text-shadow: 0 0 2px rgba(0, 0, 0, 0.4);
}
.play-icon::before {
margin: 0;
}
&.html {
flex-basis: 90%;
width: 100%;
display: flex;
}
.hider {
position: absolute;
+ right: 0;
white-space: nowrap;
margin: 10px;
padding: 5px;
background: rgba(230,230,230,0.6);
font-weight: bold;
z-index: 4;
line-height: 1;
border-radius: $fallback--tooltipRadius;
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
}
video {
z-index: 0;
}
audio {
width: 100%;
}
img.media-upload {
line-height: 0;
max-height: 200px;
max-width: 100%;
}
.oembed {
line-height: 1.2em;
flex: 1 0 100%;
width: 100%;
margin-right: 15px;
display: flex;
img {
width: 100%;
}
.image {
flex: 1;
img {
border: 0px;
border-radius: 5px;
height: 100%;
object-fit: cover;
}
}
.text {
flex: 2;
margin: 8px;
word-break: break-all;
h1 {
font-size: 14px;
margin: 0px;
}
}
}
.image-attachment {
width: 100%;
height: 100%;
&.hidden {
display: none;
}
.nsfw {
object-fit: cover;
width: 100%;
height: 100%;
}
img {
image-orientation: from-image;
}
}
}
</style>
diff --git a/src/components/basic_user_card/basic_user_card.js b/src/components/basic_user_card/basic_user_card.js
index a844144691..87085a2824 100644
--- a/src/components/basic_user_card/basic_user_card.js
+++ b/src/components/basic_user_card/basic_user_card.js
@@ -1,28 +1,28 @@
-import UserCardContent from '../user_card_content/user_card_content.vue'
+import UserCard from '../user_card/user_card.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
const BasicUserCard = {
props: [
'user'
],
data () {
return {
userExpanded: false
}
},
components: {
- UserCardContent,
+ UserCard,
UserAvatar
},
methods: {
toggleUserExpanded () {
this.userExpanded = !this.userExpanded
},
userProfileLink (user) {
return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames)
}
}
}
export default BasicUserCard
diff --git a/src/components/basic_user_card/basic_user_card.vue b/src/components/basic_user_card/basic_user_card.vue
index 77fb0aa0f7..9b80c72b81 100644
--- a/src/components/basic_user_card/basic_user_card.vue
+++ b/src/components/basic_user_card/basic_user_card.vue
@@ -1,79 +1,62 @@
<template>
- <div class="user-card">
+ <div class="basic-user-card">
<router-link :to="userProfileLink(user)">
<UserAvatar class="avatar" @click.prevent.native="toggleUserExpanded" :src="user.profile_image_url"/>
</router-link>
- <div class="user-card-expanded-content" v-if="userExpanded">
- <user-card-content :user="user" :switcher="false"></user-card-content>
+ <div class="basic-user-card-expanded-content" v-if="userExpanded">
+ <UserCard :user="user" :rounded="true" :bordered="true"/>
</div>
- <div class="user-card-collapsed-content" v-else>
- <div :title="user.name" class="user-card-user-name">
+ <div class="basic-user-card-collapsed-content" v-else>
+ <div :title="user.name" class="basic-user-card-user-name">
<span v-if="user.name_html" v-html="user.name_html"></span>
<span v-else>{{ user.name }}</span>
</div>
<div>
- <router-link class="user-card-screen-name" :to="userProfileLink(user)">
+ <router-link class="basic-user-card-screen-name" :to="userProfileLink(user)">
@{{user.screen_name}}
</router-link>
</div>
<slot></slot>
</div>
</div>
</template>
<script src="./basic_user_card.js"></script>
<style lang="scss">
@import '../../_variables.scss';
-.user-card {
+.basic-user-card {
display: flex;
flex: 1 0;
+ margin: 0;
padding-top: 0.6em;
padding-right: 1em;
padding-bottom: 0.6em;
padding-left: 1em;
border-bottom: 1px solid;
- margin: 0;
border-bottom-color: $fallback--border;
border-bottom-color: var(--border, $fallback--border);
&-collapsed-content {
margin-left: 0.7em;
text-align: left;
flex: 1;
min-width: 0;
}
&-user-name {
img {
object-fit: contain;
height: 16px;
width: 16px;
vertical-align: middle;
}
}
&-expanded-content {
flex: 1;
margin-left: 0.7em;
- border-radius: $fallback--panelRadius;
- border-radius: var(--panelRadius, $fallback--panelRadius);
- border-style: solid;
- border-color: $fallback--border;
- border-color: var(--border, $fallback--border);
- border-width: 1px;
- overflow: hidden;
-
- .panel-heading {
- background: transparent;
- flex-direction: column;
- align-items: stretch;
- }
-
- p {
- margin-bottom: 0;
- }
}
}
</style>
diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js
index 7d9807deb3..fe5b701840 100644
--- a/src/components/notification/notification.js
+++ b/src/components/notification/notification.js
@@ -1,38 +1,38 @@
import Status from '../status/status.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
-import UserCardContent from '../user_card_content/user_card_content.vue'
+import UserCard from '../user_card/user_card.vue'
import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
const Notification = {
data () {
return {
userExpanded: false,
betterShadow: this.$store.state.interface.browserSupport.cssFilter
}
},
props: [ 'notification' ],
components: {
- Status, UserAvatar, UserCardContent
+ Status, UserAvatar, UserCard
},
methods: {
toggleUserExpanded () {
this.userExpanded = !this.userExpanded
},
userProfileLink (user) {
return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames)
}
},
computed: {
userClass () {
return highlightClass(this.notification.action.user)
},
userStyle () {
const highlight = this.$store.state.config.highlight
const user = this.notification.action.user
return highlightStyle(highlight[user.screen_name])
}
}
}
export default Notification
diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue
index 87925cfc08..5e9cef97d5 100644
--- a/src/components/notification/notification.vue
+++ b/src/components/notification/notification.vue
@@ -1,46 +1,44 @@
<template>
<status v-if="notification.type === 'mention'" :compact="true" :statusoid="notification.status"></status>
<div class="non-mention" :class="[userClass, { highlighted: userStyle }]" :style="[ userStyle ]"v-else>
<a class='avatar-container' :href="notification.action.user.statusnet_profile_url" @click.stop.prevent.capture="toggleUserExpanded">
<UserAvatar :compact="true" :betterShadow="betterShadow" :src="notification.action.user.profile_image_url_original"/>
</a>
<div class='notification-right'>
- <div class="usercard notification-usercard" v-if="userExpanded">
- <user-card-content :user="notification.action.user" :switcher="false"></user-card-content>
- </div>
+ <UserCard :user="notification.action.user" :rounded="true" :bordered="true" v-if="userExpanded"/>
<span class="notification-details">
<div class="name-and-action">
<span class="username" v-if="!!notification.action.user.name_html" :title="'@'+notification.action.user.screen_name" v-html="notification.action.user.name_html"></span>
<span class="username" v-else :title="'@'+notification.action.user.screen_name">{{ notification.action.user.name }}</span>
<span v-if="notification.type === 'like'">
<i class="fa icon-star lit"></i>
<small>{{$t('notifications.favorited_you')}}</small>
</span>
<span v-if="notification.type === 'repeat'">
<i class="fa icon-retweet lit" :title="$t('tool_tip.repeat')"></i>
<small>{{$t('notifications.repeated_you')}}</small>
</span>
<span v-if="notification.type === 'follow'">
<i class="fa icon-user-plus lit"></i>
<small>{{$t('notifications.followed_you')}}</small>
</span>
</div>
<div class="timeago">
<router-link v-if="notification.status" :to="{ name: 'conversation', params: { id: notification.status.id } }" class="faint-link">
<timeago :since="notification.action.created_at" :auto-update="240"></timeago>
</router-link>
</div>
</span>
<div class="follow-text" v-if="notification.type === 'follow'">
<router-link :to="userProfileLink(notification.action.user)">
@{{notification.action.user.screen_name}}
</router-link>
</div>
<template v-else>
<status class="faint" :compact="true" :statusoid="notification.status" :noHeading="true"></status>
</template>
</div>
</div>
</template>
<script src="./notification.js"></script>
diff --git a/src/components/notifications/notifications.js b/src/components/notifications/notifications.js
index 5e95631a99..9fc5e38a28 100644
--- a/src/components/notifications/notifications.js
+++ b/src/components/notifications/notifications.js
@@ -1,75 +1,76 @@
import Notification from '../notification/notification.vue'
import notificationsFetcher from '../../services/notifications_fetcher/notifications_fetcher.service.js'
import {
notificationsFromStore,
visibleNotificationsFromStore,
unseenNotificationsFromStore
} from '../../services/notification_utils/notification_utils.js'
const Notifications = {
created () {
const store = this.$store
const credentials = store.state.users.currentUser.credentials
- notificationsFetcher.startFetching({ store, credentials })
+ const fetcherId = notificationsFetcher.startFetching({ store, credentials })
+ this.$store.commit('setNotificationFetcher', { fetcherId })
},
data () {
return {
bottomedOut: false
}
},
computed: {
notifications () {
return notificationsFromStore(this.$store)
},
error () {
return this.$store.state.statuses.notifications.error
},
unseenNotifications () {
return unseenNotificationsFromStore(this.$store)
},
visibleNotifications () {
return visibleNotificationsFromStore(this.$store)
},
unseenCount () {
return this.unseenNotifications.length
},
loading () {
return this.$store.state.statuses.notifications.loading
}
},
components: {
Notification
},
watch: {
unseenCount (count) {
if (count > 0) {
this.$store.dispatch('setPageTitle', `(${count})`)
} else {
this.$store.dispatch('setPageTitle', '')
}
}
},
methods: {
markAsSeen () {
this.$store.dispatch('markNotificationsAsSeen', this.visibleNotifications)
},
fetchOlderNotifications () {
const store = this.$store
const credentials = store.state.users.currentUser.credentials
store.commit('setNotificationsLoading', { value: true })
notificationsFetcher.fetchAndUpdate({
store,
credentials,
older: true
}).then(notifs => {
store.commit('setNotificationsLoading', { value: false })
if (notifs.length === 0) {
this.bottomedOut = true
}
})
}
}
}
export default Notifications
diff --git a/src/components/notifications/notifications.scss b/src/components/notifications/notifications.scss
index 2240c10a8f..c0b458ccf4 100644
--- a/src/components/notifications/notifications.scss
+++ b/src/components/notifications/notifications.scss
@@ -1,175 +1,171 @@
@import '../../_variables.scss';
.notifications {
// a bit of a hack to allow scrolling below notifications
padding-bottom: 15em;
.loadmore-error {
color: $fallback--text;
color: var(--text, $fallback--text);
}
.notification {
position: relative;
.notification-overlay {
position: absolute;
top: 0;
right: 0;
left: 0;
bottom: 0;
pointer-events: none;
}
&.unseen {
.notification-overlay {
background-image: linear-gradient(135deg, var(--badgeNotification, $fallback--cRed) 4px, transparent 10px)
}
}
}
}
.notification {
box-sizing: border-box;
display: flex;
border-bottom: 1px solid;
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
&:hover .animated.avatar {
canvas {
display: none;
}
img {
visibility: visible;
}
}
- .notification-usercard {
- margin: 0;
- }
-
.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);
}
}
padding: 0;
.media-body {
margin: 0;
}
}
}
.follow-text {
padding: 0.5em 0;
}
.status-el {
flex: 1;
}
time {
white-space: nowrap;
}
.notification-right {
flex: 1;
padding-left: 0.8em;
min-width: 0;
}
.notification-details {
min-width: 0px;
word-wrap: break-word;
line-height:18px;
position: relative;
overflow: hidden;
width: 100%;
flex: 1 1 0;
display: flex;
flex-wrap: nowrap;
justify-content: space-between;
.name-and-action {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
}
.username {
font-weight: bolder;
max-width: 100%;
text-overflow: ellipsis;
white-space: nowrap;
img {
width: 14px;
height: 14px;
vertical-align: middle;
object-fit: contain
}
}
.timeago {
margin-right: .2em;
}
.icon-retweet.lit {
color: $fallback--cGreen;
color: var(--cGreen, $fallback--cGreen);
}
.icon-user-plus.lit {
color: $fallback--cBlue;
color: var(--cBlue, $fallback--cBlue);
}
.icon-reply.lit {
color: $fallback--cBlue;
color: var(--cBlue, $fallback--cBlue);
}
.icon-star.lit {
color: orange;
color: $fallback--cOrange;
color: var(--cOrange, $fallback--cOrange);
}
.status-content {
margin: 0;
max-height: 300px;
}
h1 {
word-break: break-all;
margin: 0 0 0.3em;
padding: 0;
font-size: 1em;
line-height:20px;
small {
font-weight: lighter;
}
}
p {
margin: 0;
margin-top: 0;
margin-bottom: 0.3em;
}
}
}
diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js
index c28c51bf45..23a2c7e270 100644
--- a/src/components/post_status_form/post_status_form.js
+++ b/src/components/post_status_form/post_status_form.js
@@ -1,329 +1,332 @@
import statusPoster from '../../services/status_poster/status_poster.service.js'
import MediaUpload from '../media_upload/media_upload.vue'
import fileTypeService from '../../services/file_type/file_type.service.js'
import Completion from '../../services/completion/completion.js'
import { take, filter, reject, map, uniqBy } from 'lodash'
const buildMentionsString = ({user, attentions}, currentUser) => {
let allAttentions = [...attentions]
allAttentions.unshift(user)
allAttentions = uniqBy(allAttentions, 'id')
allAttentions = reject(allAttentions, {id: currentUser.id})
let mentions = map(allAttentions, (attention) => {
return `@${attention.screen_name}`
})
return mentions.length > 0 ? mentions.join(' ') + ' ' : ''
}
const PostStatusForm = {
props: [
'replyTo',
'repliedUser',
'attentions',
'copyMessageScope',
'subject'
],
components: {
MediaUpload
},
mounted () {
this.resize(this.$refs.textarea)
const textLength = this.$refs.textarea.value.length
this.$refs.textarea.setSelectionRange(textLength, textLength)
if (this.replyTo) {
this.$refs.textarea.focus()
}
},
data () {
const preset = this.$route.query.message
let statusText = preset || ''
const scopeCopy = typeof this.$store.state.config.scopeCopy === 'undefined'
? this.$store.state.instance.scopeCopy
: this.$store.state.config.scopeCopy
if (this.replyTo) {
const currentUser = this.$store.state.users.currentUser
statusText = buildMentionsString({ user: this.repliedUser, attentions: this.attentions }, currentUser)
}
const scope = (this.copyMessageScope && scopeCopy || this.copyMessageScope === 'direct')
? this.copyMessageScope
: this.$store.state.users.currentUser.default_scope
const contentType = typeof this.$store.state.config.postContentType === 'undefined'
? this.$store.state.instance.postContentType
: this.$store.state.config.postContentType
return {
dropFiles: [],
submitDisabled: false,
error: null,
posting: false,
highlighted: 0,
newStatus: {
spoilerText: this.subject || '',
status: statusText,
nsfw: false,
files: [],
visibility: scope,
contentType
},
caret: 0
}
},
computed: {
vis () {
return {
public: { selected: this.newStatus.visibility === 'public' },
unlisted: { selected: this.newStatus.visibility === 'unlisted' },
private: { selected: this.newStatus.visibility === 'private' },
direct: { selected: this.newStatus.visibility === 'direct' }
}
},
candidates () {
const firstchar = this.textAtCaret.charAt(0)
if (firstchar === '@') {
const query = this.textAtCaret.slice(1).toUpperCase()
const matchedUsers = filter(this.users, (user) => {
return user.screen_name.toUpperCase().startsWith(query) ||
user.name && user.name.toUpperCase().startsWith(query)
})
if (matchedUsers.length <= 0) {
return false
}
// eslint-disable-next-line camelcase
return map(take(matchedUsers, 5), ({screen_name, name, profile_image_url_original}, index) => ({
// eslint-disable-next-line camelcase
screen_name: `@${screen_name}`,
name: name,
img: profile_image_url_original,
highlighted: index === this.highlighted
}))
} else if (firstchar === ':') {
if (this.textAtCaret === ':') { return }
const matchedEmoji = filter(this.emoji.concat(this.customEmoji), (emoji) => emoji.shortcode.startsWith(this.textAtCaret.slice(1)))
if (matchedEmoji.length <= 0) {
return false
}
return map(take(matchedEmoji, 5), ({shortcode, image_url, utf}, index) => ({
screen_name: `:${shortcode}:`,
name: '',
utf: utf || '',
// eslint-disable-next-line camelcase
img: utf ? '' : this.$store.state.instance.server + image_url,
highlighted: index === this.highlighted
}))
} else {
return false
}
},
textAtCaret () {
return (this.wordAtCaret || {}).word || ''
},
wordAtCaret () {
const word = Completion.wordAtPosition(this.newStatus.status, this.caret - 1) || {}
return word
},
users () {
return this.$store.state.users.users
},
emoji () {
return this.$store.state.instance.emoji || []
},
customEmoji () {
return this.$store.state.instance.customEmoji || []
},
statusLength () {
return this.newStatus.status.length
},
spoilerTextLength () {
return this.newStatus.spoilerText.length
},
statusLengthLimit () {
return this.$store.state.instance.textlimit
},
hasStatusLengthLimit () {
return this.statusLengthLimit > 0
},
charactersLeft () {
return this.statusLengthLimit - (this.statusLength + this.spoilerTextLength)
},
isOverLengthLimit () {
return this.hasStatusLengthLimit && (this.charactersLeft < 0)
},
scopeOptionsEnabled () {
return this.$store.state.instance.scopeOptionsEnabled
},
alwaysShowSubject () {
if (typeof this.$store.state.config.alwaysShowSubjectInput !== 'undefined') {
return this.$store.state.config.alwaysShowSubjectInput
} else if (typeof this.$store.state.instance.alwaysShowSubjectInput !== 'undefined') {
return this.$store.state.instance.alwaysShowSubjectInput
} else {
return this.$store.state.instance.scopeOptionsEnabled
}
},
formattingOptionsEnabled () {
return this.$store.state.instance.formattingOptionsEnabled
+ },
+ postFormats () {
+ return this.$store.state.instance.postFormats || []
}
},
methods: {
replace (replacement) {
this.newStatus.status = Completion.replaceWord(this.newStatus.status, this.wordAtCaret, replacement)
const el = this.$el.querySelector('textarea')
el.focus()
this.caret = 0
},
replaceCandidate (e) {
const len = this.candidates.length || 0
if (this.textAtCaret === ':' || e.ctrlKey) { return }
if (len > 0) {
e.preventDefault()
const candidate = this.candidates[this.highlighted]
const replacement = candidate.utf || (candidate.screen_name + ' ')
this.newStatus.status = Completion.replaceWord(this.newStatus.status, this.wordAtCaret, replacement)
const el = this.$el.querySelector('textarea')
el.focus()
this.caret = 0
this.highlighted = 0
}
},
cycleBackward (e) {
const len = this.candidates.length || 0
if (len > 0) {
e.preventDefault()
this.highlighted -= 1
if (this.highlighted < 0) {
this.highlighted = this.candidates.length - 1
}
} else {
this.highlighted = 0
}
},
cycleForward (e) {
const len = this.candidates.length || 0
if (len > 0) {
if (e.shiftKey) { return }
e.preventDefault()
this.highlighted += 1
if (this.highlighted >= len) {
this.highlighted = 0
}
} else {
this.highlighted = 0
}
},
setCaret ({target: {selectionStart}}) {
this.caret = selectionStart
},
postStatus (newStatus) {
if (this.posting) { return }
if (this.submitDisabled) { return }
if (this.newStatus.status === '') {
if (this.newStatus.files.length > 0) {
this.newStatus.status = '\u200b' // hack
} else {
this.error = 'Cannot post an empty status with no files'
return
}
}
this.posting = true
statusPoster.postStatus({
status: newStatus.status,
spoilerText: newStatus.spoilerText || null,
visibility: newStatus.visibility,
sensitive: newStatus.nsfw,
media: newStatus.files,
store: this.$store,
inReplyToStatusId: this.replyTo,
contentType: newStatus.contentType
}).then((data) => {
if (!data.error) {
this.newStatus = {
status: '',
spoilerText: '',
files: [],
visibility: newStatus.visibility,
contentType: newStatus.contentType
}
this.$refs.mediaUpload.clearFile()
this.$emit('posted')
let el = this.$el.querySelector('textarea')
el.style.height = 'auto'
el.style.height = undefined
this.error = null
} else {
this.error = data.error
}
this.posting = false
})
},
addMediaFile (fileInfo) {
this.newStatus.files.push(fileInfo)
this.enableSubmit()
},
removeMediaFile (fileInfo) {
let index = this.newStatus.files.indexOf(fileInfo)
this.newStatus.files.splice(index, 1)
},
uploadFailed (errString, templateArgs) {
templateArgs = templateArgs || {}
this.error = this.$t('upload.error.base') + ' ' + this.$t('upload.error.' + errString, templateArgs)
this.enableSubmit()
},
disableSubmit () {
this.submitDisabled = true
},
enableSubmit () {
this.submitDisabled = false
},
type (fileInfo) {
return fileTypeService.fileType(fileInfo.mimetype)
},
paste (e) {
if (e.clipboardData.files.length > 0) {
// Strangely, files property gets emptied after event propagation
// Trying to wrap it in array doesn't work. Plus I doubt it's possible
// to hold more than one file in clipboard.
this.dropFiles = [e.clipboardData.files[0]]
}
},
fileDrop (e) {
if (e.dataTransfer.files.length > 0) {
e.preventDefault() // allow dropping text like before
this.dropFiles = e.dataTransfer.files
}
},
fileDrag (e) {
e.dataTransfer.dropEffect = 'copy'
},
resize (e) {
const target = e.target || e
if (!(target instanceof window.Element)) { return }
const vertPadding = Number(window.getComputedStyle(target)['padding-top'].substr(0, 1)) +
Number(window.getComputedStyle(target)['padding-bottom'].substr(0, 1))
// Auto is needed to make textbox shrink when removing lines
target.style.height = 'auto'
target.style.height = `${target.scrollHeight - vertPadding}px`
if (target.value === '') {
target.style.height = null
}
},
clearError () {
this.error = null
},
changeVis (visibility) {
this.newStatus.visibility = visibility
}
}
}
export default PostStatusForm
diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue
index 40d24b9774..0ddde4ea63 100644
--- a/src/components/post_status_form/post_status_form.vue
+++ b/src/components/post_status_form/post_status_form.vue
@@ -1,311 +1,311 @@
<template>
<div class="post-status-form">
<form @submit.prevent="postStatus(newStatus)">
<div class="form-group" >
<i18n
v-if="!this.$store.state.users.currentUser.locked && this.newStatus.visibility == 'private'"
path="post_status.account_not_locked_warning"
tag="p"
class="visibility-notice">
<router-link :to="{ name: 'user-settings' }">{{ $t('post_status.account_not_locked_warning_link') }}</router-link>
</i18n>
<p v-if="this.newStatus.visibility == 'direct'" class="visibility-notice">{{ $t('post_status.direct_warning') }}</p>
<input
v-if="newStatus.spoilerText || alwaysShowSubject"
type="text"
:placeholder="$t('post_status.content_warning')"
v-model="newStatus.spoilerText"
class="form-cw">
<textarea
ref="textarea"
@click="setCaret"
@keyup="setCaret" v-model="newStatus.status" :placeholder="$t('post_status.default')" rows="1" class="form-control"
@keydown.down="cycleForward"
@keydown.up="cycleBackward"
@keydown.shift.tab="cycleBackward"
@keydown.tab="cycleForward"
@keydown.enter="replaceCandidate"
@keydown.meta.enter="postStatus(newStatus)"
@keyup.ctrl.enter="postStatus(newStatus)"
@drop="fileDrop"
@dragover.prevent="fileDrag"
@input="resize"
@paste="paste"
:disabled="posting"
>
</textarea>
<div class="visibility-tray">
<span class="text-format" v-if="formattingOptionsEnabled">
<label for="post-content-type" class="select">
<select id="post-content-type" v-model="newStatus.contentType" class="form-control">
- <option value="text/plain">{{$t('post_status.content_type.plain_text')}}</option>
- <option value="text/html">HTML</option>
- <option value="text/markdown">Markdown</option>
+ <option v-for="postFormat in postFormats" :key="postFormat" :value="postFormat">
+ {{$t(`post_status.content_type["${postFormat}"]`)}}
+ </option>
</select>
<i class="icon-down-open"></i>
</label>
</span>
<div v-if="scopeOptionsEnabled">
<i v-on:click="changeVis('direct')" class="icon-mail-alt" :class="vis.direct" :title="$t('post_status.scope.direct')"></i>
<i v-on:click="changeVis('private')" class="icon-lock" :class="vis.private" :title="$t('post_status.scope.private')"></i>
<i v-on:click="changeVis('unlisted')" class="icon-lock-open-alt" :class="vis.unlisted" :title="$t('post_status.scope.unlisted')"></i>
<i v-on:click="changeVis('public')" class="icon-globe" :class="vis.public" :title="$t('post_status.scope.public')"></i>
</div>
</div>
</div>
<div style="position:relative;" v-if="candidates">
<div class="autocomplete-panel">
<div v-for="candidate in candidates" @click="replace(candidate.utf || (candidate.screen_name + ' '))">
<div class="autocomplete" :class="{ highlighted: candidate.highlighted }">
<span v-if="candidate.img"><img :src="candidate.img"></img></span>
<span v-else>{{candidate.utf}}</span>
<span>{{candidate.screen_name}}<small>{{candidate.name}}</small></span>
</div>
</div>
</div>
</div>
<div class='form-bottom'>
<media-upload ref="mediaUpload" @uploading="disableSubmit" @uploaded="addMediaFile" @upload-failed="uploadFailed" :drop-files="dropFiles"></media-upload>
<p v-if="isOverLengthLimit" class="error">{{ charactersLeft }}</p>
<p class="faint" v-else-if="hasStatusLengthLimit">{{ charactersLeft }}</p>
<button v-if="posting" disabled class="btn btn-default">{{$t('post_status.posting')}}</button>
<button v-else-if="isOverLengthLimit" disabled class="btn btn-default">{{$t('general.submit')}}</button>
<button v-else :disabled="submitDisabled" type="submit" class="btn btn-default">{{$t('general.submit')}}</button>
</div>
<div class='alert error' v-if="error">
Error: {{ error }}
<i class="button-icon icon-cancel" @click="clearError"></i>
</div>
<div class="attachments">
<div class="media-upload-wrapper" v-for="file in newStatus.files">
<i class="fa button-icon icon-cancel" @click="removeMediaFile(file)"></i>
<div class="media-upload-container attachment">
<img class="thumbnail media-upload" :src="file.image" v-if="type(file) === 'image'"></img>
<video v-if="type(file) === 'video'" :src="file.image" controls></video>
<audio v-if="type(file) === 'audio'" :src="file.image" controls></audio>
<a v-if="type(file) === 'unknown'" :href="file.image">{{file.url}}</a>
</div>
</div>
</div>
<div class="upload_settings" v-if="newStatus.files.length > 0">
<input type="checkbox" id="filesSensitive" v-model="newStatus.nsfw">
<label for="filesSensitive">{{$t('post_status.attachments_sensitive')}}</label>
</div>
</form>
</div>
</template>
<script src="./post_status_form.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.tribute-container {
ul {
padding: 0px;
li {
display: flex;
align-items: center;
}
}
img {
padding: 3px;
width: 16px;
height: 16px;
border-radius: $fallback--avatarAltRadius;
border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
}
}
.post-status-form {
.visibility-tray {
display: flex;
justify-content: space-between;
flex-direction: row-reverse;
}
}
.post-status-form, .login {
.form-bottom {
display: flex;
padding: 0.5em;
height: 32px;
button {
width: 10em;
}
p {
margin: 0.35em;
padding: 0.35em;
display: flex;
}
}
.error {
text-align: center;
}
.media-upload-wrapper {
flex: 0 0 auto;
max-width: 100%;
min-width: 50px;
margin-right: .2em;
margin-bottom: .5em;
.icon-cancel {
display: inline-block;
position: static;
margin: 0;
padding-bottom: 0;
margin-left: $fallback--attachmentRadius;
margin-left: var(--attachmentRadius, $fallback--attachmentRadius);
background-color: $fallback--fg;
background-color: var(--btn, $fallback--fg);
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
}
.attachments {
padding: 0 0.5em;
.attachment {
margin: 0;
position: relative;
flex: 0 0 auto;
border: 1px solid $fallback--border;
border: 1px solid var(--border, $fallback--border);
text-align: center;
audio {
min-width: 300px;
flex: 1 0 auto;
}
a {
display: block;
text-align: left;
line-height: 1.2;
padding: .5em;
}
}
i {
position: absolute;
margin: 10px;
padding: 5px;
background: rgba(230,230,230,0.6);
border-radius: $fallback--attachmentRadius;
border-radius: var(--attachmentRadius, $fallback--attachmentRadius);
font-weight: bold;
}
}
.btn {
cursor: pointer;
}
.btn[disabled] {
cursor: not-allowed;
}
form {
display: flex;
flex-direction: column;
padding: 0.6em;
}
.form-group {
display: flex;
flex-direction: column;
padding: 0.3em 0.5em 0.6em;
line-height:24px;
}
form textarea.form-cw {
line-height:16px;
resize: none;
overflow: hidden;
transition: min-height 200ms 100ms;
min-height: 1px;
}
form textarea.form-control {
line-height:16px;
resize: none;
overflow: hidden;
transition: min-height 200ms 100ms;
min-height: 1px;
box-sizing: content-box;
}
form textarea.form-control:focus {
min-height: 48px;
}
.btn {
cursor: pointer;
}
.btn[disabled] {
cursor: not-allowed;
}
.icon-cancel {
cursor: pointer;
z-index: 4;
}
.autocomplete-panel {
margin: 0 0.5em 0 0.5em;
border-radius: $fallback--tooltipRadius;
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
position: absolute;
z-index: 1;
box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5);
// this doesn't match original but i don't care, making it uniform.
box-shadow: var(--popupShadow);
min-width: 75%;
background: $fallback--bg;
background: var(--bg, $fallback--bg);
color: $fallback--lightText;
color: var(--lightText, $fallback--lightText);
}
.autocomplete {
cursor: pointer;
padding: 0.2em 0.4em 0.2em 0.4em;
border-bottom: 1px solid rgba(0, 0, 0, 0.4);
display: flex;
img {
width: 24px;
height: 24px;
border-radius: $fallback--avatarRadius;
border-radius: var(--avatarRadius, $fallback--avatarRadius);
object-fit: contain;
}
span {
line-height: 24px;
margin: 0 0.1em 0 0.2em;
}
small {
margin-left: .5em;
color: $fallback--faint;
color: var(--faint, $fallback--faint);
}
&.highlighted {
background-color: $fallback--fg;
background-color: var(--lightBg, $fallback--fg);
}
}
}
</style>
diff --git a/src/components/settings/settings.js b/src/components/settings/settings.js
index 6e2dff7bec..979457a56f 100644
--- a/src/components/settings/settings.js
+++ b/src/components/settings/settings.js
@@ -1,198 +1,201 @@
/* eslint-env browser */
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 { filter, trim } from 'lodash'
const settings = {
data () {
const user = this.$store.state.config
const instance = this.$store.state.instance
return {
hideAttachmentsLocal: user.hideAttachments,
hideAttachmentsInConvLocal: user.hideAttachmentsInConv,
maxThumbnails: user.maxThumbnails,
hideNsfwLocal: user.hideNsfw,
useOneClickNsfw: user.useOneClickNsfw,
hideISPLocal: user.hideISP,
preloadImage: user.preloadImage,
hidePostStatsLocal: typeof user.hidePostStats === 'undefined'
? instance.hidePostStats
: user.hidePostStats,
hidePostStatsDefault: this.$t('settings.values.' + instance.hidePostStats),
hideUserStatsLocal: typeof user.hideUserStats === 'undefined'
? instance.hideUserStats
: user.hideUserStats,
hideUserStatsDefault: this.$t('settings.values.' + instance.hideUserStats),
hideFilteredStatusesLocal: typeof user.hideFilteredStatuses === 'undefined'
? instance.hideFilteredStatuses
: user.hideFilteredStatuses,
hideFilteredStatusesDefault: this.$t('settings.values.' + instance.hideFilteredStatuses),
notificationVisibilityLocal: user.notificationVisibility,
replyVisibilityLocal: user.replyVisibility,
loopVideoLocal: user.loopVideo,
muteWordsString: user.muteWords.join('\n'),
autoLoadLocal: user.autoLoad,
streamingLocal: user.streaming,
pauseOnUnfocusedLocal: user.pauseOnUnfocused,
hoverPreviewLocal: user.hoverPreview,
collapseMessageWithSubjectLocal: typeof user.collapseMessageWithSubject === 'undefined'
? instance.collapseMessageWithSubject
: user.collapseMessageWithSubject,
collapseMessageWithSubjectDefault: this.$t('settings.values.' + instance.collapseMessageWithSubject),
subjectLineBehaviorLocal: typeof user.subjectLineBehavior === 'undefined'
? instance.subjectLineBehavior
: user.subjectLineBehavior,
subjectLineBehaviorDefault: instance.subjectLineBehavior,
postContentTypeLocal: typeof user.postContentType === 'undefined'
? instance.postContentType
: user.postContentType,
postContentTypeDefault: instance.postContentType,
alwaysShowSubjectInputLocal: typeof user.alwaysShowSubjectInput === 'undefined'
? instance.alwaysShowSubjectInput
: user.alwaysShowSubjectInput,
alwaysShowSubjectInputDefault: instance.alwaysShowSubjectInput,
scopeCopyLocal: typeof user.scopeCopy === 'undefined'
? instance.scopeCopy
: user.scopeCopy,
scopeCopyDefault: this.$t('settings.values.' + instance.scopeCopy),
stopGifs: user.stopGifs,
webPushNotificationsLocal: user.webPushNotifications,
loopVideoSilentOnlyLocal: user.loopVideosSilentOnly,
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'),
playVideosInModal: user.playVideosInModal,
useContainFit: user.useContainFit
}
},
components: {
TabSwitcher,
StyleSwitcher,
InterfaceLanguageSwitcher
},
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 }
},
watch: {
hideAttachmentsLocal (value) {
this.$store.dispatch('setOption', { name: 'hideAttachments', value })
},
hideAttachmentsInConvLocal (value) {
this.$store.dispatch('setOption', { name: 'hideAttachmentsInConv', value })
},
hidePostStatsLocal (value) {
this.$store.dispatch('setOption', { name: 'hidePostStats', value })
},
hideUserStatsLocal (value) {
this.$store.dispatch('setOption', { name: 'hideUserStats', value })
},
hideFilteredStatusesLocal (value) {
this.$store.dispatch('setOption', { name: 'hideFilteredStatuses', value })
},
hideNsfwLocal (value) {
this.$store.dispatch('setOption', { name: 'hideNsfw', value })
},
useOneClickNsfw (value) {
this.$store.dispatch('setOption', { name: 'useOneClickNsfw', value })
},
preloadImage (value) {
this.$store.dispatch('setOption', { name: 'preloadImage', value })
},
hideISPLocal (value) {
this.$store.dispatch('setOption', { name: 'hideISP', value })
},
'notificationVisibilityLocal.likes' (value) {
this.$store.dispatch('setOption', { name: 'notificationVisibility', value: this.$store.state.config.notificationVisibility })
},
'notificationVisibilityLocal.follows' (value) {
this.$store.dispatch('setOption', { name: 'notificationVisibility', value: this.$store.state.config.notificationVisibility })
},
'notificationVisibilityLocal.repeats' (value) {
this.$store.dispatch('setOption', { name: 'notificationVisibility', value: this.$store.state.config.notificationVisibility })
},
'notificationVisibilityLocal.mentions' (value) {
this.$store.dispatch('setOption', { name: 'notificationVisibility', value: this.$store.state.config.notificationVisibility })
},
replyVisibilityLocal (value) {
this.$store.dispatch('setOption', { name: 'replyVisibility', value })
},
loopVideoLocal (value) {
this.$store.dispatch('setOption', { name: 'loopVideo', value })
},
loopVideoSilentOnlyLocal (value) {
this.$store.dispatch('setOption', { name: 'loopVideoSilentOnly', value })
},
autoLoadLocal (value) {
this.$store.dispatch('setOption', { name: 'autoLoad', value })
},
streamingLocal (value) {
this.$store.dispatch('setOption', { name: 'streaming', value })
},
pauseOnUnfocusedLocal (value) {
this.$store.dispatch('setOption', { name: 'pauseOnUnfocused', value })
},
hoverPreviewLocal (value) {
this.$store.dispatch('setOption', { name: 'hoverPreview', value })
},
muteWordsString (value) {
value = filter(value.split('\n'), (word) => trim(word).length > 0)
this.$store.dispatch('setOption', { name: 'muteWords', value })
},
collapseMessageWithSubjectLocal (value) {
this.$store.dispatch('setOption', { name: 'collapseMessageWithSubject', value })
},
scopeCopyLocal (value) {
this.$store.dispatch('setOption', { name: 'scopeCopy', value })
},
alwaysShowSubjectInputLocal (value) {
this.$store.dispatch('setOption', { name: 'alwaysShowSubjectInput', value })
},
subjectLineBehaviorLocal (value) {
this.$store.dispatch('setOption', { name: 'subjectLineBehavior', value })
},
postContentTypeLocal (value) {
this.$store.dispatch('setOption', { name: 'postContentType', value })
},
stopGifs (value) {
this.$store.dispatch('setOption', { name: 'stopGifs', value })
},
webPushNotificationsLocal (value) {
this.$store.dispatch('setOption', { name: 'webPushNotifications', value })
if (value) this.$store.dispatch('registerPushNotifications')
},
playVideosInModal (value) {
this.$store.dispatch('setOption', { name: 'playVideosInModal', value })
},
useContainFit (value) {
this.$store.dispatch('setOption', { name: 'useContainFit', value })
},
maxThumbnails (value) {
value = this.maxThumbnails = Math.floor(Math.max(value, 0))
this.$store.dispatch('setOption', { name: 'maxThumbnails', value })
}
}
}
export default settings
diff --git a/src/components/settings/settings.vue b/src/components/settings/settings.vue
index 5041b3a392..d234674728 100644
--- a/src/components/settings/settings.vue
+++ b/src/components/settings/settings.vue
@@ -1,346 +1,338 @@
<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 @click.prevent class="alert error" v-if="currentSaveStateNotice.error">
{{ $t('settings.saving_err') }}
</div>
<div @click.prevent class="alert transparent" v-if="!currentSaveStateNotice.error">
{{ $t('settings.saving_ok') }}
</div>
</template>
</transition>
</div>
<div class="panel-body">
<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">
<input type="checkbox" id="hideISP" v-model="hideISPLocal">
<label for="hideISP">{{$t('settings.hide_isp')}}</label>
</li>
</ul>
</div>
<div class="setting-item">
<h2>{{$t('nav.timeline')}}</h2>
<ul class="setting-list">
<li>
<input type="checkbox" id="collapseMessageWithSubject" v-model="collapseMessageWithSubjectLocal">
<label for="collapseMessageWithSubject">
{{$t('settings.collapse_subject')}} {{$t('settings.instance_default', { value: collapseMessageWithSubjectDefault })}}
</label>
</li>
<li>
<input type="checkbox" id="streaming" v-model="streamingLocal">
<label for="streaming">{{$t('settings.streaming')}}</label>
<ul class="setting-list suboptions" :class="[{disabled: !streamingLocal}]">
<li>
<input :disabled="!streamingLocal" type="checkbox" id="pauseOnUnfocused" v-model="pauseOnUnfocusedLocal">
<label for="pauseOnUnfocused">{{$t('settings.pause_on_unfocused')}}</label>
</li>
</ul>
</li>
<li>
<input type="checkbox" id="autoload" v-model="autoLoadLocal">
<label for="autoload">{{$t('settings.autoload')}}</label>
</li>
<li>
<input type="checkbox" id="hoverPreview" v-model="hoverPreviewLocal">
<label for="hoverPreview">{{$t('settings.reply_link_preview')}}</label>
</li>
</ul>
</div>
<div class="setting-item">
<h2>{{$t('settings.composing')}}</h2>
<ul class="setting-list">
<li>
<input type="checkbox" id="scopeCopy" v-model="scopeCopyLocal">
<label for="scopeCopy">
{{$t('settings.scope_copy')}} {{$t('settings.instance_default', { value: scopeCopyDefault })}}
</label>
</li>
<li>
<input type="checkbox" id="subjectHide" v-model="alwaysShowSubjectInputLocal">
<label for="subjectHide">
{{$t('settings.subject_input_always_show')}} {{$t('settings.instance_default', { value: alwaysShowSubjectInputDefault })}}
</label>
</li>
<li>
<div>
{{$t('settings.subject_line_behavior')}}
<label for="subjectLineBehavior" class="select">
<select id="subjectLineBehavior" v-model="subjectLineBehaviorLocal">
<option value="email">
{{$t('settings.subject_line_email')}}
{{subjectLineBehaviorDefault == 'email' ? $t('settings.instance_default_simple') : ''}}
</option>
<option value="masto">
{{$t('settings.subject_line_mastodon')}}
{{subjectLineBehaviorDefault == 'mastodon' ? $t('settings.instance_default_simple') : ''}}
</option>
<option value="noop">
{{$t('settings.subject_line_noop')}}
{{subjectLineBehaviorDefault == 'noop' ? $t('settings.instance_default_simple') : ''}}
</option>
</select>
<i class="icon-down-open"/>
</label>
</div>
</li>
<li>
<div>
{{$t('settings.post_status_content_type')}}
<label for="postContentType" class="select">
<select id="postContentType" v-model="postContentTypeLocal">
- <option value="text/plain">
- {{$t('settings.status_content_type_plain')}}
- {{postContentTypeDefault == 'text/plain' ? $t('settings.instance_default_simple') : ''}}
- </option>
- <option value="text/html">
- HTML
- {{postContentTypeDefault == 'text/html' ? $t('settings.instance_default_simple') : ''}}
- </option>
- <option value="text/markdown">
- Markdown
- {{postContentTypeDefault == 'text/markdown' ? $t('settings.instance_default_simple') : ''}}
+ <option v-for="postFormat in postFormats" :key="postFormat" :value="postFormat">
+ {{$t(`post_status.content_type["${postFormat}"]`)}}
+ {{postContentTypeDefault === postFormat ? $t('settings.instance_default_simple') : ''}}
</option>
</select>
<i class="icon-down-open"/>
</label>
</div>
</li>
</ul>
</div>
<div class="setting-item">
<h2>{{$t('settings.attachments')}}</h2>
<ul class="setting-list">
<li>
<input type="checkbox" id="hideAttachments" v-model="hideAttachmentsLocal">
<label for="hideAttachments">{{$t('settings.hide_attachments_in_tl')}}</label>
</li>
<li>
<input type="checkbox" id="hideAttachmentsInConv" v-model="hideAttachmentsInConvLocal">
<label for="hideAttachmentsInConv">{{$t('settings.hide_attachments_in_convo')}}</label>
</li>
<li>
<label for="maxThumbnails">{{$t('settings.max_thumbnails')}}</label>
<input class="number-input" type="number" id="maxThumbnails" v-model.number="maxThumbnails" min="0" step="1">
</li>
<li>
<input type="checkbox" id="hideNsfw" v-model="hideNsfwLocal">
<label for="hideNsfw">{{$t('settings.nsfw_clickthrough')}}</label>
</li>
<ul class="setting-list suboptions" >
<li>
<input :disabled="!hideNsfwLocal" type="checkbox" id="preloadImage" v-model="preloadImage">
<label for="preloadImage">{{$t('settings.preload_images')}}</label>
</li>
<li>
<input :disabled="!hideNsfwLocal" type="checkbox" id="useOneClickNsfw" v-model="useOneClickNsfw">
<label for="useOneClickNsfw">{{$t('settings.use_one_click_nsfw')}}</label>
</li>
</ul>
<li>
<input type="checkbox" id="stopGifs" v-model="stopGifs">
<label for="stopGifs">{{$t('settings.stop_gifs')}}</label>
</li>
<li>
<input type="checkbox" id="loopVideo" v-model="loopVideoLocal">
<label for="loopVideo">{{$t('settings.loop_video')}}</label>
<ul class="setting-list suboptions" :class="[{disabled: !streamingLocal}]">
<li>
<input :disabled="!loopVideoLocal || !loopSilentAvailable" type="checkbox" id="loopVideoSilentOnly" v-model="loopVideoSilentOnlyLocal">
<label for="loopVideoSilentOnly">{{$t('settings.loop_video_silent_only')}}</label>
<div v-if="!loopSilentAvailable" class="unavailable">
<i class="icon-globe"/>! {{$t('settings.limited_availability')}}
</div>
</li>
</ul>
</li>
<li>
<input type="checkbox" id="playVideosInModal" v-model="playVideosInModal">
<label for="playVideosInModal">{{$t('settings.play_videos_in_modal')}}</label>
</li>
<li>
<input type="checkbox" id="useContainFit" v-model="useContainFit">
<label for="useContainFit">{{$t('settings.use_contain_fit')}}</label>
</li>
</ul>
</div>
<div class="setting-item">
<h2>{{$t('settings.notifications')}}</h2>
<ul class="setting-list">
<li>
<input type="checkbox" id="webPushNotifications" v-model="webPushNotificationsLocal">
<label for="webPushNotifications">
{{$t('settings.enable_web_push_notifications')}}
</label>
</li>
</ul>
</div>
</div>
<div :label="$t('settings.theme')" >
<div class="setting-item">
<style-switcher></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>
<input type="checkbox" id="notification-visibility-likes" v-model="notificationVisibilityLocal.likes">
<label for="notification-visibility-likes">
{{$t('settings.notification_visibility_likes')}}
</label>
</li>
<li>
<input type="checkbox" id="notification-visibility-repeats" v-model="notificationVisibilityLocal.repeats">
<label for="notification-visibility-repeats">
{{$t('settings.notification_visibility_repeats')}}
</label>
</li>
<li>
<input type="checkbox" id="notification-visibility-follows" v-model="notificationVisibilityLocal.follows">
<label for="notification-visibility-follows">
{{$t('settings.notification_visibility_follows')}}
</label>
</li>
<li>
<input type="checkbox" id="notification-visibility-mentions" v-model="notificationVisibilityLocal.mentions">
<label for="notification-visibility-mentions">
{{$t('settings.notification_visibility_mentions')}}
</label>
</li>
</ul>
</div>
<div>
{{$t('settings.replies_in_timeline')}}
<label for="replyVisibility" class="select">
<select id="replyVisibility" v-model="replyVisibilityLocal">
<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>
<input type="checkbox" id="hidePostStats" v-model="hidePostStatsLocal">
<label for="hidePostStats">
{{$t('settings.hide_post_stats')}} {{$t('settings.instance_default', { value: hidePostStatsDefault })}}
</label>
</div>
<div>
<input type="checkbox" id="hideUserStats" v-model="hideUserStatsLocal">
<label for="hideUserStats">
{{$t('settings.hide_user_stats')}} {{$t('settings.instance_default', { value: hideUserStatsDefault })}}
</label>
</div>
</div>
<div class="setting-item">
<div>
<p>{{$t('settings.filtering_explanation')}}</p>
<textarea id="muteWords" v-model="muteWordsString"></textarea>
</div>
<div>
<input type="checkbox" id="hideFilteredStatuses" v-model="hideFilteredStatusesLocal">
<label for="hideFilteredStatuses">
{{$t('settings.hide_filtered_statuses')}} {{$t('settings.instance_default', { value: hideFilteredStatusesDefault })}}
</label>
</div>
</div>
</div>
</tab-switcher>
</keep-alive>
</div>
</div>
</template>
<script src="./settings.js">
</script>
<style lang="scss">
@import '../../_variables.scss';
.setting-item {
border-bottom: 2px solid var(--fg, $fallback--fg);
margin: 1em 1em 1.4em;
padding-bottom: 1.4em;
> div {
margin-bottom: .5em;
&:last-child {
margin-bottom: 0;
}
}
&:last-child {
border-bottom: none;
padding-bottom: 0;
margin-bottom: 1em;
}
select {
min-width: 10em;
}
textarea {
width: 100%;
height: 100px;
}
.unavailable,
.unavailable i {
color: var(--cRed, $fallback--cRed);
color: $fallback--cRed;
}
.btn {
min-height: 28px;
min-width: 10em;
padding: 0 2em;
}
.number-input {
max-width: 6em;
}
}
.select-multiple {
display: flex;
.option-list {
margin: 0;
padding-left: .5em;
}
}
.setting-list,
.option-list{
list-style-type: none;
padding-left: 2em;
li {
margin-bottom: 0.5em;
}
.suboptions {
margin-top: 0.3em
}
}
</style>
diff --git a/src/components/side_drawer/side_drawer.js b/src/components/side_drawer/side_drawer.js
index b5c4905934..ad3738d167 100644
--- a/src/components/side_drawer/side_drawer.js
+++ b/src/components/side_drawer/side_drawer.js
@@ -1,60 +1,60 @@
-import UserCardContent from '../user_card_content/user_card_content.vue'
+import UserCard from '../user_card/user_card.vue'
import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils'
// TODO: separate touch gesture stuff into their own utils if more components want them
const deltaCoord = (oldCoord, newCoord) => [newCoord[0] - oldCoord[0], newCoord[1] - oldCoord[1]]
const touchEventCoord = e => ([e.touches[0].screenX, e.touches[0].screenY])
const SideDrawer = {
props: [ 'logout' ],
data: () => ({
closed: true,
touchCoord: [0, 0]
}),
- components: { UserCardContent },
+ components: { UserCard },
computed: {
currentUser () {
return this.$store.state.users.currentUser
},
chat () { return this.$store.state.chat.channel.state === 'joined' },
unseenNotifications () {
return unseenNotificationsFromStore(this.$store)
},
unseenNotificationsCount () {
return this.unseenNotifications.length
},
suggestionsEnabled () {
return this.$store.state.instance.suggestionsEnabled
},
logo () {
return this.$store.state.instance.logo
},
sitename () {
return this.$store.state.instance.name
},
followRequestCount () {
return this.$store.state.api.followRequests.length
}
},
methods: {
toggleDrawer () {
this.closed = !this.closed
},
doLogout () {
this.logout()
this.toggleDrawer()
},
touchStart (e) {
this.touchCoord = touchEventCoord(e)
},
touchMove (e) {
const delta = deltaCoord(this.touchCoord, touchEventCoord(e))
if (delta[0] < -30 && Math.abs(delta[1]) < Math.abs(delta[0]) && !this.closed) {
this.toggleDrawer()
}
}
}
}
export default SideDrawer
diff --git a/src/components/side_drawer/side_drawer.vue b/src/components/side_drawer/side_drawer.vue
index 9b5020e8fa..95ee21b449 100644
--- a/src/components/side_drawer/side_drawer.vue
+++ b/src/components/side_drawer/side_drawer.vue
@@ -1,218 +1,209 @@
<template>
<div class="side-drawer-container"
:class="{ 'side-drawer-container-closed': closed, 'side-drawer-container-open': !closed }"
>
<div class="side-drawer"
:class="{'side-drawer-closed': closed}"
@touchstart="touchStart"
@touchmove="touchMove"
>
<div class="side-drawer-heading" @click="toggleDrawer">
- <user-card-content :user="currentUser" :switcher="false" :hideBio="true" v-if="currentUser"/>
+ <UserCard :user="currentUser" :hideBio="true" v-if="currentUser"/>
<div class="side-drawer-logo-wrapper" v-else>
<img :src="logo"/>
<span>{{sitename}}</span>
</div>
</div>
<ul>
<li v-if="!currentUser" @click="toggleDrawer">
<router-link :to="{ name: 'login' }">
{{ $t("login.login") }}
</router-link>
</li>
<li v-if="currentUser" @click="toggleDrawer">
<router-link :to="{ name: 'notifications', params: { username: currentUser.screen_name } }">
{{ $t("notifications.notifications") }} {{ unseenNotificationsCount > 0 ? `(${unseenNotificationsCount})` : '' }}
</router-link>
</li>
<li v-if="currentUser" @click="toggleDrawer">
<router-link :to="{ name: 'dms', params: { username: currentUser.screen_name } }">
{{ $t("nav.dms") }}
</router-link>
</li>
</ul>
<ul>
<li v-if="currentUser" @click="toggleDrawer">
<router-link :to="{ name: 'friends' }">
{{ $t("nav.timeline") }}
</router-link>
</li>
<li v-if="currentUser && currentUser.locked" @click="toggleDrawer">
<router-link to='/friend-requests'>
{{ $t("nav.friend_requests") }}
<span v-if='followRequestCount > 0' class="badge follow-request-count">
{{followRequestCount}}
</span>
</router-link>
</li>
<li @click="toggleDrawer">
<router-link to='/main/public'>
{{ $t("nav.public_tl") }}
</router-link>
</li>
<li @click="toggleDrawer">
<router-link to='/main/all'>
{{ $t("nav.twkn") }}
</router-link>
</li>
<li v-if="currentUser && chat" @click="toggleDrawer">
<router-link :to="{ name: 'chat' }">
{{ $t("nav.chat") }}
</router-link>
</li>
</ul>
<ul>
<li @click="toggleDrawer">
<router-link :to="{ name: 'user-search' }">
{{ $t("nav.user_search") }}
</router-link>
</li>
<li v-if="currentUser && suggestionsEnabled" @click="toggleDrawer">
<router-link :to="{ name: 'who-to-follow' }">
{{ $t("nav.who_to_follow") }}
</router-link>
</li>
<li @click="toggleDrawer">
<router-link :to="{ name: 'settings' }">
{{ $t("settings.settings") }}
</router-link>
</li>
<li @click="toggleDrawer">
<router-link :to="{ name: 'about'}">
{{ $t("nav.about") }}
</router-link>
</li>
<li v-if="currentUser" @click="toggleDrawer">
<a @click="doLogout" href="#">
{{ $t("login.logout") }}
</a>
</li>
</ul>
</div>
<div class="side-drawer-click-outside"
@click.stop.prevent="toggleDrawer"
:class="{'side-drawer-click-outside-closed': closed}"
></div>
</div>
</template>
<script src="./side_drawer.js" ></script>
<style lang="scss">
@import '../../_variables.scss';
.side-drawer-container {
position: fixed;
z-index: 1000;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: stretch;
}
.side-drawer-container-open {
transition: 0.35s;
transition-property: background-color;
background-color: rgba(0, 0, 0, 0.5);
}
.side-drawer-container-closed {
left: -100%;
background-color: rgba(0, 0, 0, 0);
}
.side-drawer-click-outside {
flex: 1 1 100%;
}
.side-drawer {
overflow-x: hidden;
transition: 0.35s;
transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
margin: 0 0 0 -100px;
padding: 0 0 1em 100px;
width: 80%;
max-width: 20em;
flex: 0 0 80%;
box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.6);
box-shadow: var(--panelShadow);
background-color: $fallback--bg;
background-color: var(--bg, $fallback--bg);
}
.side-drawer-logo-wrapper {
display: flex;
align-items: center;
padding: 0.85em;
img {
flex: none;
height: 50px;
margin-right: 0.85em;
}
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.side-drawer-click-outside-closed {
flex: 0 0 0;
}
.side-drawer-closed {
transform: translate(-100%);
}
.side-drawer-heading {
background: transparent;
flex-direction: column;
align-items: stretch;
display: flex;
padding: 0;
margin: 0;
-
- .profile-panel-background {
- border-radius: 0;
- .panel-heading {
- background: transparent;
- flex-direction: column;
- align-items: stretch;
- }
- }
}
.side-drawer ul {
list-style: none;
margin: 0;
padding: 0;
border-bottom: 1px solid;
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
margin: 0.2em 0;
}
.side-drawer ul:last-child {
border: 0;
}
.side-drawer li {
padding: 0;
a {
display: block;
padding: 0.5em 0.85em;
&:hover {
background-color: $fallback--lightBg;
background-color: var(--lightBg, $fallback--lightBg);
}
}
}
</style>
diff --git a/src/components/status/status.js b/src/components/status/status.js
index fbbca6c49d..9e18fe151c 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -1,394 +1,394 @@
import Attachment from '../attachment/attachment.vue'
import FavoriteButton from '../favorite_button/favorite_button.vue'
import RetweetButton from '../retweet_button/retweet_button.vue'
import DeleteButton from '../delete_button/delete_button.vue'
import PostStatusForm from '../post_status_form/post_status_form.vue'
-import UserCardContent from '../user_card_content/user_card_content.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 generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import fileType from 'src/services/file_type/file_type.service'
import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
import { mentionMatchesUrl, extractTagFromUrl } from 'src/services/matcher/matcher.service.js'
import { filter, find, unescape } from 'lodash'
const Status = {
name: 'Status',
props: [
'statusoid',
'expandable',
'inConversation',
'focused',
'highlight',
'compact',
'replies',
'isPreview',
'noHeading',
'inlineExpanded'
],
data () {
return {
replying: false,
expanded: false,
unmuted: false,
userExpanded: false,
preview: null,
showPreview: false,
showingTall: this.inConversation && this.focused,
showingLongSubject: false,
expandingSubject: typeof this.$store.state.config.collapseMessageWithSubject === 'undefined'
? !this.$store.state.instance.collapseMessageWithSubject
: !this.$store.state.config.collapseMessageWithSubject,
betterShadow: this.$store.state.interface.browserSupport.cssFilter
}
},
computed: {
localCollapseSubjectDefault () {
return typeof this.$store.state.config.collapseMessageWithSubject === 'undefined'
? this.$store.state.instance.collapseMessageWithSubject
: this.$store.state.config.collapseMessageWithSubject
},
muteWords () {
return this.$store.state.config.muteWords
},
repeaterClass () {
const user = this.statusoid.user
return highlightClass(user)
},
userClass () {
const user = this.retweet ? (this.statusoid.retweeted_status.user) : this.statusoid.user
return highlightClass(user)
},
deleted () {
return this.statusoid.deleted
},
repeaterStyle () {
const user = this.statusoid.user
const highlight = this.$store.state.config.highlight
return highlightStyle(highlight[user.screen_name])
},
userStyle () {
if (this.noHeading) return
const user = this.retweet ? (this.statusoid.retweeted_status.user) : this.statusoid.user
const highlight = this.$store.state.config.highlight
return highlightStyle(highlight[user.screen_name])
},
hideAttachments () {
return (this.$store.state.config.hideAttachments && !this.inConversation) ||
(this.$store.state.config.hideAttachmentsInConv && this.inConversation)
},
userProfileLink () {
return this.generateUserProfileLink(this.status.user.id, this.status.user.screen_name)
},
replyProfileLink () {
if (this.isReply) {
return this.generateUserProfileLink(this.status.in_reply_to_user_id, this.replyToName)
}
},
retweet () { return !!this.statusoid.retweeted_status },
retweeter () { return this.statusoid.user.name || this.statusoid.user.screen_name },
retweeterHtml () { return this.statusoid.user.name_html },
retweeterProfileLink () { return this.generateUserProfileLink(this.statusoid.user.id, this.statusoid.user.screen_name) },
status () {
if (this.retweet) {
return this.statusoid.retweeted_status
} else {
return this.statusoid
}
},
loggedIn () {
return !!this.$store.state.users.currentUser
},
muteWordHits () {
const statusText = this.status.text.toLowerCase()
const hits = filter(this.muteWords, (muteWord) => {
return statusText.includes(muteWord.toLowerCase())
})
return hits
},
muted () { return !this.unmuted && (this.status.user.muted || this.muteWordHits.length > 0) },
hideFilteredStatuses () {
return typeof this.$store.state.config.hideFilteredStatuses === 'undefined'
? this.$store.state.instance.hideFilteredStatuses
: this.$store.state.config.hideFilteredStatuses
},
hideStatus () {
return (this.hideReply || this.deleted) || (this.muted && this.hideFilteredStatuses)
},
isFocused () {
// retweet or root of an expanded conversation
if (this.focused) {
return true
} else if (!this.inConversation) {
return false
}
// 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)
},
replyToName () {
const user = this.$store.state.users.usersObject[this.status.in_reply_to_user_id]
if (user) {
return user.screen_name
} else {
return this.status.in_reply_to_screen_name
}
},
hideReply () {
if (this.$store.state.config.replyVisibility === 'all') {
return false
}
if (this.inlineExpanded || this.expanded || this.inConversation || !this.isReply) {
return false
}
if (this.status.user.id === this.$store.state.users.currentUser.id) {
return false
}
if (this.status.type === 'retweet') {
return false
}
var checkFollowing = this.$store.state.config.replyVisibility === 'following'
for (var i = 0; i < this.status.attentions.length; ++i) {
if (this.status.user.id === this.status.attentions[i].id) {
continue
}
if (checkFollowing && this.status.attentions[i].following) {
return false
}
if (this.status.attentions[i].id === this.$store.state.users.currentUser.id) {
return false
}
}
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)
const behavior = typeof this.$store.state.config.subjectLineBehavior === 'undefined'
? this.$store.state.instance.subjectLineBehavior
: this.$store.state.config.subjectLineBehavior
const startsWithRe = decodedSummary.match(/^re[: ]/i)
if (behavior !== 'noop' && startsWithRe || behavior === 'masto') {
return decodedSummary
} else if (behavior === 'email') {
return 're: '.concat(decodedSummary)
} else if (behavior === 'noop') {
return ''
}
},
attachmentSize () {
if ((this.$store.state.config.hideAttachments && !this.inConversation) ||
(this.$store.state.config.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.$store.state.config.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)
)
},
maxThumbnails () {
return this.$store.state.config.maxThumbnails
}
},
components: {
Attachment,
FavoriteButton,
RetweetButton,
DeleteButton,
PostStatusForm,
- UserCardContent,
+ UserCard,
UserAvatar,
Gallery,
LinkPreview
},
methods: {
visibilityIcon (visibility) {
switch (visibility) {
case 'private':
return 'icon-lock'
case 'unlisted':
return 'icon-lock-open-alt'
case 'direct':
return 'icon-mail-alt'
default:
return 'icon-globe'
}
},
linkClicked (event) {
let { target } = event
if (target.tagName === 'SPAN') {
target = target.parentNode
}
if (target.tagName === 'A') {
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.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
},
gotoOriginal (id) {
// only handled by conversation, not status_or_conversation
if (this.inConversation) {
this.$emit('goto', id)
}
},
toggleExpanded () {
this.$emit('toggleExpanded')
},
toggleMute () {
this.unmuted = !this.unmuted
},
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
}
},
replyEnter (id, event) {
this.showPreview = true
const targetId = id
const statuses = this.$store.state.statuses.allStatuses
if (!this.preview) {
// if we have the status somewhere already
this.preview = find(statuses, { 'id': targetId })
// or if we have to fetch it
if (!this.preview) {
this.$store.state.api.backendInteractor.fetchStatus({id}).then((status) => {
this.preview = status
})
}
} else if (this.preview.id !== targetId) {
this.preview = find(statuses, { 'id': targetId })
}
},
replyLeave () {
this.showPreview = false
},
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: {
'highlight': function (id) {
if (this.status.id === id) {
let rect = this.$el.getBoundingClientRect()
if (rect.top < 100) {
// Post is above screen, match its top to screen top
window.scrollBy(0, rect.top - 100)
} else if (rect.height >= (window.innerHeight - 50)) {
// Post we want to see is taller than screen so match its top to screen top
window.scrollBy(0, rect.top - 100)
} else if (rect.bottom > window.innerHeight - 50) {
// Post is below screen, match its bottom to screen bottom
window.scrollBy(0, rect.bottom - window.innerHeight + 50)
}
}
}
},
filters: {
capitalize: function (str) {
return str.charAt(0).toUpperCase() + str.slice(1)
}
}
}
export default Status
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index 4dd2036245..1f6d0325ee 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -1,641 +1,638 @@
<template>
<div class="status-el" v-if="!hideStatus" :class="[{ 'status-el_focused': isFocused }, { 'status-conversation': inlineExpanded }]">
<template v-if="muted && !isPreview">
<div class="media status container muted">
<small>
<router-link :to="userProfileLink">
{{status.user.screen_name}}
</router-link>
</small>
<small class="muteWords">{{muteWordHits.join(', ')}}</small>
<a href="#" class="unmute" @click.prevent="toggleMute"><i class="button-icon icon-eye-off"></i></a>
</div>
</template>
<template v-else>
<div v-if="retweet && !noHeading" :class="[repeaterClass, { highlighted: repeaterStyle }]" :style="[repeaterStyle]" class="media container retweet-info">
<UserAvatar class="media-left" v-if="retweet" :betterShadow="betterShadow" :src="statusoid.user.profile_image_url_original"/>
<div class="media-body faint">
<span class="user-name">
<router-link v-if="retweeterHtml" :to="retweeterProfileLink" v-html="retweeterHtml"/>
<router-link v-else :to="retweeterProfileLink">{{retweeter}}</router-link>
</span>
<i class='fa icon-retweet retweeted' :title="$t('tool_tip.repeat')"></i>
{{$t('timeline.repeated')}}
</div>
</div>
<div :class="[userClass, { highlighted: userStyle, 'is-retweet': retweet }]" :style="[ userStyle ]" class="media status">
<div v-if="!noHeading" class="media-left">
<router-link :to="userProfileLink" @click.stop.prevent.capture.native="toggleUserExpanded">
<UserAvatar :compact="compact" :betterShadow="betterShadow" :src="status.user.profile_image_url_original"/>
</router-link>
</div>
<div class="status-body">
- <div class="usercard" v-if="userExpanded">
- <user-card-content :user="status.user" :switcher="false"></user-card-content>
- </div>
+ <UserCard :user="status.user" :rounded="true" :bordered="true" class="status-usercard" v-if="userExpanded"/>
<div v-if="!noHeading" class="media-heading">
<div class="heading-name-row">
<div class="name-and-account-name">
<h4 class="user-name" v-if="status.user.name_html" v-html="status.user.name_html"></h4>
<h4 class="user-name" v-else>{{status.user.name}}</h4>
<router-link class="account-name" :to="userProfileLink">
{{status.user.screen_name}}
</router-link>
</div>
<span class="heading-right">
<router-link class="timeago faint-link" :to="{ name: 'conversation', params: { id: status.id } }">
<timeago :since="status.created_at" :auto-update="60"></timeago>
</router-link>
<div class="button-icon visibility-icon" v-if="status.visibility">
<i :class="visibilityIcon(status.visibility)" :title="status.visibility | capitalize"></i>
</div>
<a :href="status.external_url" target="_blank" v-if="!status.is_local && !isPreview" class="source_url" title="Source">
<i class="button-icon icon-link-ext-alt"></i>
</a>
<template v-if="expandable && !isPreview">
<a href="#" @click.prevent="toggleExpanded" title="Expand">
<i class="button-icon icon-plus-squared"></i>
</a>
</template>
<a href="#" @click.prevent="toggleMute" v-if="unmuted"><i class="button-icon icon-eye-off"></i></a>
</span>
</div>
<div class="heading-reply-row">
<div v-if="isReply" class="reply-to-and-accountname">
<a class="reply-to"
href="#" @click.prevent="gotoOriginal(status.in_reply_to_status_id)"
:aria-label="$t('tool_tip.reply')"
@mouseenter.prevent.stop="replyEnter(status.in_reply_to_status_id, $event)"
@mouseleave.prevent.stop="replyLeave()"
>
<i class="button-icon icon-reply" v-if="!isPreview"></i>
<span class="faint-link reply-to-text">{{$t('status.reply_to')}}</span>
</a>
<router-link :to="replyProfileLink">
{{replyToName}}
</router-link>
<span class="faint replies-separator" v-if="replies && replies.length">
-
</span>
</div>
<div class="replies" v-if="inConversation && !isPreview">
<span class="faint" v-if="replies && replies.length">{{$t('status.replies_list')}}</span>
<span class="reply-link faint" v-if="replies" v-for="reply in replies">
<a href="#" @click.prevent="gotoOriginal(reply.id)" @mouseenter="replyEnter(reply.id, $event)" @mouseout="replyLeave()">{{reply.name}}</a>
</span>
</div>
</div>
</div>
<div v-if="showPreview" class="status-preview-container">
<status class="status-preview" v-if="preview" :isPreview="true" :statusoid="preview" :compact=true></status>
<div class="status-preview status-preview-loading" v-else>
<i class="icon-spin4 animate-spin"></i>
</div>
</div>
<div class="status-content-wrapper" :class="{ 'tall-status': !showingLongSubject }" v-if="longSubject">
<a class="tall-status-hider" :class="{ 'tall-status-hider_focused': isFocused }" v-if="!showingLongSubject" href="#" @click.prevent="showingLongSubject=true">Show more</a>
<div @click.prevent="linkClicked" class="status-content media-body" v-html="status.statusnet_html"></div>
<a v-if="showingLongSubject" href="#" class="status-unhider" @click.prevent="showingLongSubject=false">Show less</a>
</div>
<div :class="{'tall-status': hideTallStatus}" class="status-content-wrapper" v-else>
<a class="tall-status-hider" :class="{ 'tall-status-hider_focused': isFocused }" v-if="hideTallStatus" href="#" @click.prevent="toggleShowMore">Show more</a>
<div @click.prevent="linkClicked" class="status-content media-body" v-html="status.statusnet_html" v-if="!hideSubjectStatus"></div>
<div @click.prevent="linkClicked" class="status-content media-body" v-html="status.summary_html" v-else></div>
<a v-if="hideSubjectStatus" href="#" class="cw-status-hider" @click.prevent="toggleShowMore">Show more</a>
<a v-if="showingMore" href="#" class="status-unhider" @click.prevent="toggleShowMore">Show less</a>
</div>
<div v-if="status.attachments && (!hideSubjectStatus || showingLongSubject)" class="attachments media-body">
<attachment
class="non-gallery"
v-for="attachment in nonGalleryAttachments"
:size="attachmentSize"
:nsfw="nsfwClickthrough"
:attachment="attachment"
:allowPlay="true"
:setMedia="setMedia()"
:key="attachment.id"
/>
<gallery
v-if="galleryAttachments.length > 0"
:nsfw="nsfwClickthrough"
:attachments="galleryAttachments"
:setMedia="setMedia()"
/>
</div>
<div v-if="status.card && !hideSubjectStatus && !noHeading" class="link-preview media-body">
<link-preview :card="status.card" :size="attachmentSize" :nsfw="nsfwClickthrough" />
</div>
<div v-if="!noHeading && !isPreview" class='status-actions media-body'>
<div v-if="loggedIn">
<a href="#" v-on:click.prevent="toggleReplying" :title="$t('tool_tip.reply')">
<i class="button-icon icon-reply" :class="{'icon-reply-active': replying}"></i>
</a>
</div>
<retweet-button :visibility='status.visibility' :loggedIn='loggedIn' :status='status'></retweet-button>
<favorite-button :loggedIn='loggedIn' :status='status'></favorite-button>
<delete-button :status='status'></delete-button>
</div>
</div>
</div>
<div class="container" v-if="replying">
<div class="reply-left"/>
<post-status-form class="reply-body" :reply-to="status.id" :attentions="status.attentions" :repliedUser="status.user" :copy-message-scope="status.visibility" :subject="replySubject" v-on:posted="toggleReplying"/>
</div>
</template>
</div>
</template>
<script src="./status.js" ></script>
<style lang="scss">
@import '../../_variables.scss';
$status-margin: 0.75em;
.status-body {
flex: 1;
min-width: 0;
}
.status-preview.status-el {
border-style: solid;
border-width: 1px;
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
}
.status-preview-container {
position: relative;
max-width: 100%;
}
.status-preview {
position: absolute;
max-width: 95%;
display: flex;
background-color: $fallback--bg;
background-color: var(--bg, $fallback--bg);
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
border-style: solid;
border-width: 1px;
border-radius: $fallback--tooltipRadius;
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
box-shadow: 2px 2px 3px rgba(0, 0, 0, 0.5);
box-shadow: var(--popupShadow);
margin-top: 0.25em;
margin-left: 0.5em;
z-index: 50;
.status {
flex: 1;
border: 0;
min-width: 15em;
}
}
.status-preview-loading {
display: block;
min-width: 15em;
padding: 1em;
text-align: center;
border-width: 1px;
border-style: solid;
i {
font-size: 2em;
}
}
.media-left {
margin-right: $status-margin;
}
.status-el {
hyphens: auto;
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
border-left-width: 0px;
min-width: 0;
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
border-left: 4px $fallback--cRed;
border-left: 4px var(--cRed, $fallback--cRed);
&_focused {
background-color: $fallback--lightBg;
background-color: var(--lightBg, $fallback--lightBg);
}
.timeline & {
border-bottom-width: 1px;
border-bottom-style: solid;
}
.media-body {
flex: 1;
padding: 0;
}
- .usercard {
- margin: 0;
+ .status-usercard {
margin-bottom: $status-margin;
}
.user-name {
white-space: nowrap;
font-size: 14px;
overflow: hidden;
flex-shrink: 0;
max-width: 85%;
font-weight: bold;
img {
width: 14px;
height: 14px;
vertical-align: middle;
object-fit: contain
}
}
.media-heading {
padding: 0;
vertical-align: bottom;
flex-basis: 100%;
margin-bottom: 0.5em;
a {
display: inline-block;
word-break: break-all;
}
small {
font-weight: lighter;
}
.heading-name-row {
padding: 0;
display: flex;
justify-content: space-between;
line-height: 18px;
.name-and-account-name {
display: flex;
min-width: 0;
}
.user-name {
flex-shrink: 1;
margin-right: 0.4em;
overflow: hidden;
text-overflow: ellipsis;
}
.account-name {
min-width: 1.6em;
margin-right: 0.4em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1 1 0;
}
}
.heading-right {
display: flex;
flex-shrink: 0;
}
.timeago {
margin-right: 0.2em;
}
.heading-reply-row {
align-content: baseline;
font-size: 12px;
line-height: 18px;
max-width: 100%;
display: flex;
flex-wrap: wrap;
align-items: stretch;
a {
max-width: 100%;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
}
.reply-to-and-accountname {
display: flex;
height: 18px;
margin-right: 0.5em;
overflow: hidden;
max-width: 100%;
.icon-reply {
transform: scaleX(-1);
}
}
.reply-info {
display: flex;
}
.reply-to {
display: flex;
}
.reply-to-text {
overflow: hidden;
text-overflow: ellipsis;
margin: 0 0.4em 0 0.2em;
}
.replies-separator {
margin-left: 0.4em;
}
.replies {
line-height: 18px;
font-size: 12px;
display: flex;
flex-wrap: wrap;
& > * {
margin-right: 0.4em;
}
}
.reply-link {
height: 17px;
}
}
.tall-status {
position: relative;
height: 220px;
overflow-x: hidden;
overflow-y: hidden;
}
.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;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0), $fallback--bg 80%);
background: linear-gradient(to bottom, rgba(0, 0, 0, 0), var(--bg, $fallback--bg) 80%);
&_focused {
background: linear-gradient(to bottom, rgba(0, 0, 0, 0), $fallback--lightBg 80%);
background: linear-gradient(to bottom, rgba(0, 0, 0, 0), var(--lightBg, $fallback--lightBg) 80%);
}
}
.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;
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;
.avatar.still-image {
border-radius: $fallback--avatarAltRadius;
border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
margin-left: 28px;
width: 20px;
height: 20px;
}
.media-body {
font-size: 1em;
line-height: 22px;
display: flex;
align-content: center;
flex-wrap: wrap;
.user-name {
font-weight: bold;
overflow: hidden;
text-overflow: ellipsis;
img {
width: 14px;
height: 14px;
vertical-align: middle;
object-fit: contain
}
}
i {
padding: 0 0.2em;
}
a {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}
.status-fadein {
animation-duration: 0.4s;
animation-name: fadein;
}
@keyframes fadein {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.greentext {
color: green;
}
.status-conversation {
border-left-style: solid;
}
.status-actions {
width: 100%;
display: flex;
margin-top: $status-margin;
div, favorite-button {
max-width: 4em;
flex: 1;
}
}
.icon-reply:hover {
color: $fallback--cBlue;
color: var(--cBlue, $fallback--cBlue);
}
.icon-reply.icon-reply-active {
color: $fallback--cBlue;
color: var(--cBlue, $fallback--cBlue);
}
.status:hover .animated.avatar {
canvas {
display: none;
}
img {
visibility: visible;
}
}
.status {
display: flex;
padding: $status-margin;
&.is-retweet {
padding-top: 0;
}
}
.status-conversation:last-child {
border-bottom: none;
}
.muted {
padding: 0.25em 0.5em;
button {
margin-left: auto;
}
.muteWords {
margin-left: 10px;
}
}
a.unmute {
display: block;
margin-left: auto;
}
.reply-left {
flex: 0;
min-width: 48px;
}
.reply-body {
flex: 1;
}
.timeline > {
.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;
}
}
@media all and (max-width: 800px) {
.status-el {
.retweet-info {
.avatar.still-image {
margin-left: 20px;
}
}
}
.status {
max-width: 100%;
}
.status .avatar.still-image {
width: 40px;
height: 40px;
&.avatar-compact {
width: 32px;
height: 32px;
}
}
}
</style>
diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js
index 655bfb3faf..c45f89470d 100644
--- a/src/components/timeline/timeline.js
+++ b/src/components/timeline/timeline.js
@@ -1,148 +1,150 @@
import Status from '../status/status.vue'
import timelineFetcher from '../../services/timeline_fetcher/timeline_fetcher.service.js'
import StatusOrConversation from '../status_or_conversation/status_or_conversation.vue'
import { throttle } from 'lodash'
const Timeline = {
props: [
'timeline',
'timelineName',
'title',
'userId',
'tag',
'embedded',
'count'
],
data () {
return {
paused: false,
unfocused: false,
bottomedOut: false
}
},
computed: {
timelineError () { return this.$store.state.statuses.error },
newStatusCount () {
return this.timeline.newStatusCount
},
newStatusCountStr () {
if (this.timeline.flushMarker !== 0) {
return ''
} else {
return ` (${this.newStatusCount})`
}
},
classes () {
return {
root: ['timeline'].concat(!this.embedded ? ['panel', 'panel-default'] : []),
header: ['timeline-heading'].concat(!this.embedded ? ['panel-heading'] : []),
body: ['timeline-body'].concat(!this.embedded ? ['panel-body'] : []),
footer: ['timeline-footer'].concat(!this.embedded ? ['panel-footer'] : [])
}
}
},
components: {
Status,
StatusOrConversation
},
created () {
const store = this.$store
const credentials = store.state.users.currentUser.credentials
const showImmediately = this.timeline.visibleStatuses.length === 0
window.addEventListener('scroll', this.scrollLoad)
if (this.timelineName === 'friends' && !credentials) { return false }
timelineFetcher.fetchAndUpdate({
store,
credentials,
timeline: this.timelineName,
showImmediately,
userId: this.userId,
tag: this.tag
})
},
mounted () {
if (typeof document.hidden !== 'undefined') {
document.addEventListener('visibilitychange', this.handleVisibilityChange, false)
this.unfocused = document.hidden
}
window.addEventListener('keydown', this.handleShortKey)
},
destroyed () {
window.removeEventListener('scroll', this.scrollLoad)
window.removeEventListener('keydown', this.handleShortKey)
if (typeof document.hidden !== 'undefined') document.removeEventListener('visibilitychange', this.handleVisibilityChange, false)
this.$store.commit('setLoading', { timeline: this.timelineName, value: false })
},
methods: {
handleShortKey (e) {
if (e.key === '.') this.showNewStatuses()
},
showNewStatuses () {
if (this.newStatusCount === 0) return
if (this.timeline.flushMarker !== 0) {
this.$store.commit('clearTimeline', { timeline: this.timelineName })
this.$store.commit('queueFlush', { timeline: this.timelineName, id: 0 })
this.fetchOlderStatuses()
} else {
this.$store.commit('showNewStatuses', { timeline: this.timelineName })
this.paused = false
}
},
fetchOlderStatuses: throttle(function () {
const store = this.$store
const credentials = store.state.users.currentUser.credentials
store.commit('setLoading', { timeline: this.timelineName, value: true })
timelineFetcher.fetchAndUpdate({
store,
credentials,
timeline: this.timelineName,
older: true,
showImmediately: true,
userId: this.userId,
tag: this.tag
}).then(statuses => {
store.commit('setLoading', { timeline: this.timelineName, value: false })
if (statuses && statuses.length === 0) {
this.bottomedOut = true
}
})
}, 1000, this),
scrollLoad (e) {
const bodyBRect = document.body.getBoundingClientRect()
const height = Math.max(bodyBRect.height, -(bodyBRect.y))
if (this.timeline.loading === false &&
this.$store.state.config.autoLoad &&
this.$el.offsetHeight > 0 &&
(window.innerHeight + window.pageYOffset) >= (height - 750)) {
this.fetchOlderStatuses()
}
},
handleVisibilityChange () {
this.unfocused = document.hidden
}
},
watch: {
newStatusCount (count) {
if (!this.$store.state.config.streaming) {
return
}
if (count > 0) {
// only 'stream' them when you're scrolled to the top
- if (window.pageYOffset < 15 &&
+ const doc = document.documentElement
+ const top = (window.pageYOffset || doc.scrollTop) - (doc.clientTop || 0)
+ if (top < 15 &&
!this.paused &&
!(this.unfocused && this.$store.state.config.pauseOnUnfocused)
) {
this.showNewStatuses()
} else {
this.paused = true
}
}
}
}
}
export default Timeline
diff --git a/src/components/user_card_content/user_card_content.js b/src/components/user_card/user_card.js
similarity index 81%
rename from src/components/user_card_content/user_card_content.js
rename to src/components/user_card/user_card.js
index 7a7b89d490..80d15a2746 100644
--- a/src/components/user_card_content/user_card_content.js
+++ b/src/components/user_card/user_card.js
@@ -1,141 +1,156 @@
import UserAvatar from '../user_avatar/user_avatar.vue'
import { hex2rgb } from '../../services/color_convert/color_convert.js'
import { requestFollow, requestUnfollow } from '../../services/follow_manipulate/follow_manipulate'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
export default {
- props: [ 'user', 'switcher', 'selected', 'hideBio' ],
+ props: [ 'user', 'switcher', 'selected', 'hideBio', 'rounded', 'bordered' ],
data () {
return {
followRequestInProgress: false,
followRequestSent: false,
hideUserStatsLocal: typeof this.$store.state.config.hideUserStats === 'undefined'
? this.$store.state.instance.hideUserStats
: this.$store.state.config.hideUserStats,
betterShadow: this.$store.state.interface.browserSupport.cssFilter
}
},
computed: {
- headingStyle () {
+ classes () {
+ return [{
+ 'user-card-rounded-t': this.rounded === 'top', // set border-top-left-radius and border-top-right-radius
+ 'user-card-rounded': this.rounded === true, // set border-radius for all sides
+ 'user-card-bordered': this.bordered === true // set border for all sides
+ }]
+ },
+ style () {
const color = this.$store.state.config.customTheme.colors
? this.$store.state.config.customTheme.colors.bg // v2
: this.$store.state.config.colors.bg // v1
if (color) {
const rgb = (typeof color === 'string') ? hex2rgb(color) : color
const tintColor = `rgba(${Math.floor(rgb.r)}, ${Math.floor(rgb.g)}, ${Math.floor(rgb.b)}, .5)`
const gradient = [
[tintColor, this.hideBio ? '60%' : ''],
this.hideBio ? [
color, '100%'
] : [
tintColor, ''
]
].map(_ => _.join(' ')).join(', ')
return {
backgroundColor: `rgb(${Math.floor(rgb.r * 0.53)}, ${Math.floor(rgb.g * 0.56)}, ${Math.floor(rgb.b * 0.59)})`,
backgroundImage: [
`linear-gradient(to bottom, ${gradient})`,
`url(${this.user.cover_photo})`
].join(', ')
}
}
},
isOtherUser () {
return this.user.id !== this.$store.state.users.currentUser.id
},
subscribeUrl () {
// eslint-disable-next-line no-undef
const serverUrl = new URL(this.user.statusnet_profile_url)
return `${serverUrl.protocol}//${serverUrl.host}/main/ostatus`
},
loggedIn () {
return this.$store.state.users.currentUser
},
dailyAvg () {
const days = Math.ceil((new Date() - new Date(this.user.created_at)) / (60 * 60 * 24 * 1000))
return Math.round(this.user.statuses_count / days)
},
userHighlightType: {
get () {
const data = this.$store.state.config.highlight[this.user.screen_name]
return data && data.type || 'disabled'
},
set (type) {
const data = this.$store.state.config.highlight[this.user.screen_name]
if (type !== 'disabled') {
this.$store.dispatch('setHighlight', { user: this.user.screen_name, color: data && data.color || '#FFFFFF', type })
} else {
this.$store.dispatch('setHighlight', { user: this.user.screen_name, color: undefined })
}
}
},
userHighlightColor: {
get () {
const data = this.$store.state.config.highlight[this.user.screen_name]
return data && data.color
},
set (color) {
this.$store.dispatch('setHighlight', { user: this.user.screen_name, color })
}
},
visibleRole () {
const validRole = (this.user.role === 'admin' || this.user.role === 'moderator')
const showRole = this.isOtherUser || this.user.show_role
return validRole && showRole && this.user.role
}
},
components: {
UserAvatar
},
methods: {
followUser () {
+ const store = this.$store
this.followRequestInProgress = true
- requestFollow(this.user, this.$store).then(({sent}) => {
+ requestFollow(this.user, store).then(({sent}) => {
this.followRequestInProgress = false
this.followRequestSent = sent
})
},
unfollowUser () {
+ const store = this.$store
this.followRequestInProgress = true
- requestUnfollow(this.user, this.$store).then(() => {
+ requestUnfollow(this.user, store).then(() => {
this.followRequestInProgress = false
+ store.commit('removeStatus', { timeline: 'friends', userId: this.user.id })
})
},
blockUser () {
const store = this.$store
store.state.api.backendInteractor.blockUser(this.user.id)
- .then((blockedUser) => store.commit('addNewUsers', [blockedUser]))
+ .then((blockedUser) => {
+ store.commit('addNewUsers', [blockedUser])
+ store.commit('removeStatus', { timeline: 'friends', userId: this.user.id })
+ store.commit('removeStatus', { timeline: 'public', userId: this.user.id })
+ store.commit('removeStatus', { timeline: 'publicAndExternal', userId: this.user.id })
+ })
},
unblockUser () {
const store = this.$store
store.state.api.backendInteractor.unblockUser(this.user.id)
.then((unblockedUser) => store.commit('addNewUsers', [unblockedUser]))
},
toggleMute () {
const store = this.$store
store.commit('setMuted', {user: this.user, muted: !this.user.muted})
store.state.api.backendInteractor.setUserMute(this.user)
},
setProfileView (v) {
if (this.switcher) {
const store = this.$store
store.commit('setProfileView', { v })
}
},
linkClicked ({target}) {
if (target.tagName === 'SPAN') {
target = target.parentNode
}
if (target.tagName === 'A') {
window.open(target.href, '_blank')
}
},
userProfileLink (user) {
return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames)
}
}
}
diff --git a/src/components/user_card_content/user_card_content.vue b/src/components/user_card/user_card.vue
similarity index 90%
rename from src/components/user_card_content/user_card_content.vue
rename to src/components/user_card/user_card.vue
index 702c338512..cc2ce6b8fe 100644
--- a/src/components/user_card_content/user_card_content.vue
+++ b/src/components/user_card/user_card.vue
@@ -1,417 +1,419 @@
<template>
-<div id="heading" class="profile-panel-background" :style="headingStyle">
- <div class="panel-heading text-center">
+<div class="user-card" :class="classes" :style="style">
+ <div class="panel-heading">
<div class='user-info'>
<div class='container'>
<router-link :to="userProfileLink(user)">
<UserAvatar :betterShadow="betterShadow" :src="user.profile_image_url_original"/>
</router-link>
<div class="name-and-screen-name">
<div class="top-line">
<div :title="user.name" class='user-name' v-if="user.name_html" v-html="user.name_html"></div>
<div :title="user.name" class='user-name' v-else>{{user.name}}</div>
<router-link :to="{ name: 'user-settings' }" v-if="!isOtherUser">
<i class="button-icon icon-cog usersettings" :title="$t('tool_tip.user_settings')"></i>
</router-link>
<a :href="user.statusnet_profile_url" target="_blank" v-if="isOtherUser && !user.is_local">
<i class="icon-link-ext usersettings"></i>
</a>
</div>
<router-link class='user-screen-name' :to="userProfileLink(user)">
<span class="handle">@{{user.screen_name}}
<span class="alert staff" v-if="!hideBio && !!visibleRole">{{visibleRole}}</span>
</span><span v-if="user.locked"><i class="icon icon-lock"></i></span>
<span v-if="!hideUserStatsLocal && !hideBio" class="dailyAvg">{{dailyAvg}} {{ $t('user_card.per_day') }}</span>
</router-link>
</div>
</div>
<div class="user-meta">
<div v-if="user.follows_you && loggedIn && isOtherUser" class="following">
{{ $t('user_card.follows_you') }}
</div>
<div class="highlighter" v-if="isOtherUser && (loggedIn || !switcher)">
<!-- id's need to be unique, otherwise vue confuses which user-card checkbox belongs to -->
<input class="userHighlightText" type="text" :id="'userHighlightColorTx'+user.id" v-if="userHighlightType !== 'disabled'" v-model="userHighlightColor"/>
<input class="userHighlightCl" type="color" :id="'userHighlightColor'+user.id" v-if="userHighlightType !== 'disabled'" v-model="userHighlightColor"/>
<label for="style-switcher" class='userHighlightSel select'>
<select class="userHighlightSel" :id="'userHighlightSel'+user.id" v-model="userHighlightType">
<option value="disabled">No highlight</option>
<option value="solid">Solid bg</option>
<option value="striped">Striped bg</option>
<option value="side">Side stripe</option>
</select>
<i class="icon-down-open"/>
</label>
</div>
</div>
<div v-if="isOtherUser" class="user-interactions">
<div class="follow" v-if="loggedIn">
<span v-if="user.following">
<!--Following them!-->
<button @click="unfollowUser" class="pressed" :disabled="followRequestInProgress" :title="$t('user_card.follow_unfollow')">
<template v-if="followRequestInProgress">
{{ $t('user_card.follow_progress') }}
</template>
<template v-else>
{{ $t('user_card.following') }}
</template>
</button>
</span>
<span v-if="!user.following">
<button @click="followUser" :disabled="followRequestInProgress" :title="followRequestSent ? $t('user_card.follow_again') : ''">
<template v-if="followRequestInProgress">
{{ $t('user_card.follow_progress') }}
</template>
<template v-else-if="followRequestSent">
{{ $t('user_card.follow_sent') }}
</template>
<template v-else>
{{ $t('user_card.follow') }}
</template>
</button>
</span>
</div>
<div class='mute' v-if='isOtherUser && loggedIn'>
<span v-if='user.muted'>
<button @click="toggleMute" class="pressed">
{{ $t('user_card.muted') }}
</button>
</span>
<span v-if='!user.muted'>
<button @click="toggleMute">
{{ $t('user_card.mute') }}
</button>
</span>
</div>
<div class="remote-follow" v-if='!loggedIn && user.is_local'>
<form method="POST" :action='subscribeUrl'>
<input type="hidden" name="nickname" :value="user.screen_name">
<input type="hidden" name="profile" value="">
<button click="submit" class="remote-button">
{{ $t('user_card.remote_follow') }}
</button>
</form>
</div>
<div class='block' v-if='isOtherUser && loggedIn'>
<span v-if='user.statusnet_blocking'>
<button @click="unblockUser" class="pressed">
{{ $t('user_card.blocked') }}
</button>
</span>
<span v-if='!user.statusnet_blocking'>
<button @click="blockUser">
{{ $t('user_card.block') }}
</button>
</span>
</div>
</div>
</div>
</div>
- <div class="panel-body profile-panel-body" v-if="!hideBio">
+ <div class="panel-body" v-if="!hideBio">
<div v-if="!hideUserStatsLocal && switcher" class="user-counts">
<div class="user-count" v-on:click.prevent="setProfileView('statuses')">
<h5>{{ $t('user_card.statuses') }}</h5>
<span>{{user.statuses_count}} <br></span>
</div>
<div class="user-count" v-on:click.prevent="setProfileView('friends')">
<h5>{{ $t('user_card.followees') }}</h5>
<span>{{user.friends_count}}</span>
</div>
<div class="user-count" v-on:click.prevent="setProfileView('followers')">
<h5>{{ $t('user_card.followers') }}</h5>
<span>{{user.followers_count}}</span>
</div>
</div>
- <p @click.prevent="linkClicked" v-if="!hideBio && user.description_html" class="profile-bio" v-html="user.description_html"></p>
- <p v-else-if="!hideBio" class="profile-bio">{{ user.description }}</p>
+ <p @click.prevent="linkClicked" v-if="!hideBio && user.description_html" class="user-card-bio" v-html="user.description_html"></p>
+ <p v-else-if="!hideBio" class="user-card-bio">{{ user.description }}</p>
</div>
</div>
</template>
-<script src="./user_card_content.js"></script>
+<script src="./user_card.js"></script>
<style lang="scss">
@import '../../_variables.scss';
-.profile-panel-background {
+.user-card {
background-size: cover;
- border-radius: $fallback--panelRadius;
- border-radius: var(--panelRadius, $fallback--panelRadius);
overflow: hidden;
- border-bottom-left-radius: 0;
- border-bottom-right-radius: 0;
-
.panel-heading {
padding: .5em 0;
text-align: center;
box-shadow: none;
+ background: transparent;
+ flex-direction: column;
+ align-items: stretch;
}
-}
-.profile-panel-body {
- word-wrap: break-word;
- background: linear-gradient(to bottom, rgba(0, 0, 0, 0), $fallback--bg 80%);
- background: linear-gradient(to bottom, rgba(0, 0, 0, 0), var(--bg, $fallback--bg) 80%);
+ .panel-body {
+ word-wrap: break-word;
+ background: linear-gradient(to bottom, rgba(0, 0, 0, 0), $fallback--bg 80%);
+ background: linear-gradient(to bottom, rgba(0, 0, 0, 0), var(--bg, $fallback--bg) 80%);
+ }
- .profile-bio {
+ p {
+ margin-bottom: 0;
+ }
+
+ &-bio {
text-align: center;
}
+
+ // Modifiers
+
+ &-rounded-t {
+ border-top-left-radius: $fallback--panelRadius;
+ border-top-left-radius: var(--panelRadius, $fallback--panelRadius);
+ border-top-right-radius: $fallback--panelRadius;
+ border-top-right-radius: var(--panelRadius, $fallback--panelRadius);
+ }
+
+ &-rounded {
+ border-radius: $fallback--panelRadius;
+ border-radius: var(--panelRadius, $fallback--panelRadius);
+ }
+
+ &-bordered {
+ border-width: 1px;
+ border-style: solid;
+ border-color: $fallback--border;
+ border-color: var(--border, $fallback--border);
+ }
}
.user-info {
color: $fallback--lightText;
color: var(--lightText, $fallback--lightText);
padding: 0 26px;
.container {
padding: 16px 0 6px;
display: flex;
max-height: 56px;
.avatar {
flex: 1 0 100%;
width: 56px;
height: 56px;
box-shadow: 0px 1px 8px rgba(0,0,0,0.75);
box-shadow: var(--avatarShadow);
object-fit: cover;
}
}
&:hover .animated.avatar {
canvas {
display: none;
}
img {
visibility: visible;
}
}
.usersettings {
color: $fallback--lightText;
color: var(--lightText, $fallback--lightText);
opacity: .8;
}
.name-and-screen-name {
display: block;
margin-left: 0.6em;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1 1 0;
// This is so that text doesn't get overlapped by avatar's shadow if it has
// big one
z-index: 1;
img {
width: 26px;
height: 26px;
vertical-align: middle;
object-fit: contain
}
.top-line {
display: flex;
}
}
.user-name {
text-overflow: ellipsis;
overflow: hidden;
flex: 1 1 auto;
margin-right: 1em;
font-size: 15px;
img {
object-fit: contain;
height: 16px;
width: 16px;
vertical-align: middle;
}
}
.user-screen-name {
color: $fallback--lightText;
color: var(--lightText, $fallback--lightText);
display: inline-block;
font-weight: light;
font-size: 15px;
padding-right: 0.1em;
width: 100%;
display: flex;
.dailyAvg {
min-width: 1px;
flex: 0 0 auto;
margin-left: 1em;
font-size: 0.7em;
color: $fallback--text;
color: var(--text, $fallback--text);
}
.handle {
min-width: 1px;
flex: 0 1 auto;
text-overflow: ellipsis;
overflow: hidden;
}
// TODO use proper colors
.staff {
text-transform: capitalize;
color: $fallback--text;
color: var(--btnText, $fallback--text);
background-color: $fallback--fg;
background-color: var(--btn, $fallback--fg);
}
}
.user-meta {
margin-bottom: .15em;
display: flex;
align-items: baseline;
font-size: 14px;
line-height: 22px;
flex-wrap: wrap;
.following {
flex: 1 0 auto;
margin: 0;
margin-bottom: .25em;
text-align: left;
}
.highlighter {
flex: 0 1 auto;
display: flex;
flex-wrap: wrap;
margin-right: -.5em;
align-self: start;
.userHighlightCl {
padding: 2px 10px;
flex: 1 0 auto;
}
.userHighlightSel,
.userHighlightSel.select {
padding-top: 0;
padding-bottom: 0;
flex: 1 0 auto;
}
.userHighlightSel.select i {
line-height: 22px;
}
.userHighlightText {
width: 70px;
flex: 1 0 auto;
}
.userHighlightCl,
.userHighlightText,
.userHighlightSel,
.userHighlightSel.select {
height: 22px;
vertical-align: top;
margin-right: .5em;
margin-bottom: .25em;
}
}
}
.user-interactions {
display: flex;
flex-flow: row wrap;
justify-content: space-between;
margin-right: -.75em;
div {
flex: 1 0 0;
margin-right: .75em;
margin-bottom: .6em;
white-space: nowrap;
}
.mute {
max-width: 220px;
min-height: 28px;
}
.remote-follow {
max-width: 220px;
min-height: 28px;
}
.follow {
max-width: 220px;
min-height: 28px;
}
button {
width: 100%;
height: 100%;
margin: 0;
}
.remote-button {
height: 28px !important;
width: 92%;
}
.pressed {
border-bottom-color: rgba(255, 255, 255, 0.2);
border-top-color: rgba(0, 0, 0, 0.2);
}
}
}
.user-counts {
display: flex;
line-height:16px;
padding: .5em 1.5em 0em 1.5em;
text-align: center;
justify-content: space-between;
color: $fallback--lightText;
color: var(--lightText, $fallback--lightText);
flex-wrap: wrap;
}
.user-count {
flex: 1 0 auto;
padding: .5em 0 .5em 0;
margin: 0 .5em;
h5 {
font-size:1em;
font-weight: bolder;
margin: 0 0 0.25em;
}
a {
text-decoration: none;
}
}
-
-.usercard {
- width: fill-available;
- border-radius: $fallback--panelRadius;
- border-radius: var(--panelRadius, $fallback--panelRadius);
- border-style: solid;
- border-color: $fallback--border;
- border-color: var(--border, $fallback--border);
- border-width: 1px;
- overflow: hidden;
-
- .panel-heading {
- background: transparent;
- flex-direction: column;
- align-items: stretch;
- }
-
- p {
- margin-bottom: 0;
- }
-}
</style>
diff --git a/src/components/user_panel/user_panel.js b/src/components/user_panel/user_panel.js
index 15804b8847..d447829070 100644
--- a/src/components/user_panel/user_panel.js
+++ b/src/components/user_panel/user_panel.js
@@ -1,16 +1,16 @@
import LoginForm from '../login_form/login_form.vue'
import PostStatusForm from '../post_status_form/post_status_form.vue'
-import UserCardContent from '../user_card_content/user_card_content.vue'
+import UserCard from '../user_card/user_card.vue'
const UserPanel = {
computed: {
user () { return this.$store.state.users.currentUser }
},
components: {
LoginForm,
PostStatusForm,
- UserCardContent
+ UserCard
}
}
export default UserPanel
diff --git a/src/components/user_panel/user_panel.vue b/src/components/user_panel/user_panel.vue
index 2d5cb500d6..8310f30e4a 100644
--- a/src/components/user_panel/user_panel.vue
+++ b/src/components/user_panel/user_panel.vue
@@ -1,23 +1,13 @@
<template>
<div class="user-panel">
<div v-if='user' class="panel panel-default" style="overflow: visible;">
- <user-card-content :user="user" :switcher="false" :hideBio="true"></user-card-content>
+ <UserCard :user="user" :hideBio="true" rounded="top"/>
<div class="panel-footer">
<post-status-form v-if='user'></post-status-form>
</div>
</div>
<login-form v-if='!user'></login-form>
</div>
</template>
<script src="./user_panel.js"></script>
-
-<style lang="scss">
-.user-panel {
- .profile-panel-background .panel-heading {
- background: transparent;
- flex-direction: column;
- align-items: stretch;
- }
-}
-</style>
diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js
index cdf1cee925..54126514ed 100644
--- a/src/components/user_profile/user_profile.js
+++ b/src/components/user_profile/user_profile.js
@@ -1,157 +1,157 @@
import { compose } from 'vue-compose'
import get from 'lodash/get'
-import UserCardContent from '../user_card_content/user_card_content.vue'
+import UserCard from '../user_card/user_card.vue'
import FollowCard from '../follow_card/follow_card.vue'
import Timeline from '../timeline/timeline.vue'
import withLoadMore from '../../hocs/with_load_more/with_load_more'
import withList from '../../hocs/with_list/with_list'
const FollowerList = compose(
withLoadMore({
fetch: (props, $store) => $store.dispatch('addFollowers', props.userId),
select: (props, $store) => get($store.getters.userById(props.userId), 'followers', []),
destory: (props, $store) => $store.dispatch('clearFollowers', props.userId),
childPropName: 'entries',
additionalPropNames: ['userId']
}),
withList({ getEntryProps: user => ({ user }) })
)(FollowCard)
const FriendList = compose(
withLoadMore({
fetch: (props, $store) => $store.dispatch('addFriends', props.userId),
select: (props, $store) => get($store.getters.userById(props.userId), 'friends', []),
destory: (props, $store) => $store.dispatch('clearFriends', props.userId),
childPropName: 'entries',
additionalPropNames: ['userId']
}),
withList({ getEntryProps: user => ({ user }) })
)(FollowCard)
const UserProfile = {
data () {
return {
error: false
}
},
created () {
this.$store.commit('clearTimeline', { timeline: 'user' })
this.$store.commit('clearTimeline', { timeline: 'favorites' })
this.$store.commit('clearTimeline', { timeline: 'media' })
this.$store.dispatch('startFetching', { timeline: 'user', userId: this.fetchBy })
this.$store.dispatch('startFetching', { timeline: 'media', userId: this.fetchBy })
this.startFetchFavorites()
if (!this.user.id) {
this.$store.dispatch('fetchUser', this.fetchBy)
.catch((reason) => {
const errorMessage = get(reason, 'error.error')
if (errorMessage === 'No user with such user_id') { // Known error
this.error = this.$t('user_profile.profile_does_not_exist')
} else if (errorMessage) {
this.error = errorMessage
} else {
this.error = this.$t('user_profile.profile_loading_error')
}
})
}
},
destroyed () {
this.cleanUp()
},
computed: {
timeline () {
return this.$store.state.statuses.timelines.user
},
favorites () {
return this.$store.state.statuses.timelines.favorites
},
media () {
return this.$store.state.statuses.timelines.media
},
userId () {
return this.$route.params.id || this.user.id
},
userName () {
return this.$route.params.name || this.user.screen_name
},
isUs () {
return this.userId && this.$store.state.users.currentUser.id &&
this.userId === this.$store.state.users.currentUser.id
},
userInStore () {
if (this.isExternal) {
return this.$store.getters.userById(this.userId)
}
return this.$store.getters.userByName(this.userName)
},
user () {
if (this.timeline.statuses[0]) {
return this.timeline.statuses[0].user
}
if (this.userInStore) {
return this.userInStore
}
return {}
},
fetchBy () {
return this.isExternal ? this.userId : this.userName
},
isExternal () {
return this.$route.name === 'external-user-profile'
},
followsTabVisible () {
return this.isUs || !this.user.hide_follows
},
followersTabVisible () {
return this.isUs || !this.user.hide_followers
}
},
methods: {
startFetchFavorites () {
if (this.isUs) {
this.$store.dispatch('startFetching', { timeline: 'favorites', userId: this.fetchBy })
}
},
startUp () {
this.$store.dispatch('startFetching', { timeline: 'user', userId: this.fetchBy })
this.$store.dispatch('startFetching', { timeline: 'media', userId: this.fetchBy })
this.startFetchFavorites()
},
cleanUp () {
this.$store.dispatch('stopFetching', 'user')
this.$store.dispatch('stopFetching', 'favorites')
this.$store.dispatch('stopFetching', 'media')
this.$store.commit('clearTimeline', { timeline: 'user' })
this.$store.commit('clearTimeline', { timeline: 'favorites' })
this.$store.commit('clearTimeline', { timeline: 'media' })
}
},
watch: {
userName () {
if (this.isExternal) {
return
}
this.cleanUp()
this.startUp()
},
userId () {
if (!this.isExternal) {
return
}
this.cleanUp()
this.startUp()
},
$route () {
this.$refs.tabSwitcher.activateTab(0)()
}
},
components: {
- UserCardContent,
+ UserCard,
Timeline,
FollowerList,
FriendList
}
}
export default UserProfile
diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue
index 8090efa56e..7d4a8b1f6f 100644
--- a/src/components/user_profile/user_profile.vue
+++ b/src/components/user_profile/user_profile.vue
@@ -1,105 +1,96 @@
<template>
<div>
<div v-if="user.id" class="user-profile panel panel-default">
- <user-card-content
- :user="user"
- :switcher="true"
- :selected="timeline.viewing"
- />
+ <UserCard :user="user" :switcher="true" :selected="timeline.viewing" rounded="top"/>
<tab-switcher :renderOnlyFocused="true" ref="tabSwitcher">
<Timeline
:label="$t('user_card.statuses')"
:disabled="!user.statuses_count"
:count="user.statuses_count"
:embedded="true"
:title="$t('user_profile.timeline_title')"
:timeline="timeline"
:timeline-name="'user'"
:user-id="fetchBy"
/>
<div :label="$t('user_card.followees')" v-if="followsTabVisible" :disabled="!user.friends_count">
<FriendList :userId="userId" />
</div>
<div :label="$t('user_card.followers')" v-if="followersTabVisible" :disabled="!user.followers_count">
<FollowerList :userId="userId" :entryProps="{noFollowsYou: isUs}" />
</div>
<Timeline
:label="$t('user_card.media')"
:disabled="!media.visibleStatuses.length"
:embedded="true" :title="$t('user_card.media')"
timeline-name="media"
:timeline="media"
:user-id="fetchBy"
/>
<Timeline
v-if="isUs"
:label="$t('user_card.favorites')"
:disabled="!favorites.visibleStatuses.length"
:embedded="true"
:title="$t('user_card.favorites')"
timeline-name="favorites"
:timeline="favorites"
/>
</tab-switcher>
</div>
<div v-else class="panel user-profile-placeholder">
<div class="panel-heading">
<div class="title">
{{ $t('settings.profile_tab') }}
</div>
</div>
<div class="panel-body">
<span v-if="error">{{ error }}</span>
<i class="icon-spin3 animate-spin" v-else></i>
</div>
</div>
</div>
</template>
<script src="./user_profile.js"></script>
<style lang="scss">
.user-profile {
flex: 2;
flex-basis: 500px;
- .profile-panel-background .panel-heading {
- background: transparent;
- flex-direction: column;
- align-items: stretch;
- }
.userlist-placeholder {
display: flex;
justify-content: center;
align-items: middle;
padding: 2em;
}
.timeline-heading {
display: flex;
justify-content: center;
.loadmore-button, .alert {
flex: 1;
}
.loadmore-button {
height: 28px;
margin: 10px .6em;
}
.title, .loadmore-text {
display: none
}
}
}
.user-profile-placeholder {
.panel-body {
display: flex;
justify-content: center;
align-items: middle;
padding: 7em;
}
}
</style>
diff --git a/src/i18n/en.json b/src/i18n/en.json
index c5a4a90d34..01fe2fba21 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -1,426 +1,427 @@
{
"chat": {
"title": "Chat"
},
"features_panel": {
"chat": "Chat",
"gopher": "Gopher",
"media_proxy": "Media proxy",
"scope_options": "Scope options",
"text_limit": "Text limit",
"title": "Features",
"who_to_follow": "Who to follow"
},
"finder": {
"error_fetching_user": "Error fetching user",
"find_user": "Find user"
},
"general": {
"apply": "Apply",
"submit": "Submit",
"more": "More",
"generic_error": "An error occured",
"optional": "optional"
},
"image_cropper": {
"crop_picture": "Crop picture",
"save": "Save",
"cancel": "Cancel"
},
"login": {
"login": "Log in",
"description": "Log in with OAuth",
"logout": "Log out",
"password": "Password",
"placeholder": "e.g. lain",
"register": "Register",
"username": "Username",
"hint": "Log in to join the discussion"
},
"media_modal": {
"previous": "Previous",
"next": "Next"
},
"nav": {
"about": "About",
"back": "Back",
"chat": "Local Chat",
"friend_requests": "Follow Requests",
"mentions": "Mentions",
"dms": "Direct Messages",
"public_tl": "Public Timeline",
"timeline": "Timeline",
"twkn": "The Whole Known Network",
"user_search": "User Search",
"who_to_follow": "Who to follow",
"preferences": "Preferences"
},
"notifications": {
"broken_favorite": "Unknown status, searching for it...",
"favorited_you": "favorited your status",
"followed_you": "followed you",
"load_older": "Load older notifications",
"notifications": "Notifications",
"read": "Read!",
"repeated_you": "repeated your status",
"no_more_notifications": "No more notifications"
},
"post_status": {
"new_status": "Post new status",
"account_not_locked_warning": "Your account is not {0}. Anyone can follow you to view your follower-only posts.",
"account_not_locked_warning_link": "locked",
"attachments_sensitive": "Mark attachments as sensitive",
"content_type": {
- "plain_text": "Plain text"
+ "text/plain": "Plain text",
+ "text/html": "HTML",
+ "text/markdown": "Markdown"
},
"content_warning": "Subject (optional)",
"default": "Just landed in L.A.",
"direct_warning": "This post will only be visible to all the mentioned users.",
"posting": "Posting",
"scope": {
"direct": "Direct - Post to mentioned users only",
"private": "Followers-only - Post to followers only",
"public": "Public - Post to public timelines",
"unlisted": "Unlisted - Do not post to public timelines"
}
},
"registration": {
"bio": "Bio",
"email": "Email",
"fullname": "Display name",
"password_confirm": "Password confirmation",
"registration": "Registration",
"token": "Invite token",
"captcha": "CAPTCHA",
"new_captcha": "Click the image to get a new captcha",
"username_placeholder": "e.g. lain",
"fullname_placeholder": "e.g. Lain Iwakura",
"bio_placeholder": "e.g.\nHi, I'm Lain\nI’m an anime girl living in suburban Japan. You may know me from the Wired.",
"validations": {
"username_required": "cannot be left blank",
"fullname_required": "cannot be left blank",
"email_required": "cannot be left blank",
"password_required": "cannot be left blank",
"password_confirmation_required": "cannot be left blank",
"password_confirmation_match": "should be the same as password"
}
},
"settings": {
"app_name": "App name",
"attachmentRadius": "Attachments",
"attachments": "Attachments",
"autoload": "Enable automatic loading when scrolled to the bottom",
"avatar": "Avatar",
"avatarAltRadius": "Avatars (Notifications)",
"avatarRadius": "Avatars",
"background": "Background",
"bio": "Bio",
"blocks_tab": "Blocks",
"btnRadius": "Buttons",
"cBlue": "Blue (Reply, follow)",
"cGreen": "Green (Retweet)",
"cOrange": "Orange (Favorite)",
"cRed": "Red (Cancel)",
"change_password": "Change Password",
"change_password_error": "There was an issue changing your password.",
"changed_password": "Password changed successfully!",
"collapse_subject": "Collapse posts with subjects",
"composing": "Composing",
"confirm_new_password": "Confirm new password",
"current_avatar": "Your current avatar",
"current_password": "Current password",
"current_profile_banner": "Your current profile banner",
"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_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.",
"avatar_size_instruction": "The recommended minimum size for avatar images is 150x150 pixels.",
"export_theme": "Save preset",
"filtering": "Filtering",
"filtering_explanation": "All statuses containing these words will be muted, one per line",
"follow_export": "Follow export",
"follow_export_button": "Export your follows to a csv file",
"follow_export_processing": "Processing, you'll soon be asked to download your file",
"follow_import": "Follow import",
"follow_import_error": "Error importing followers",
"follows_imported": "Follows imported! Processing them will take a while.",
"foreground": "Foreground",
"general": "General",
"hide_attachments_in_convo": "Hide attachments in conversations",
"hide_attachments_in_tl": "Hide attachments in timeline",
"max_thumbnails": "Maximum amount of thumbnails per post",
"hide_isp": "Hide instance-specific panel",
"preload_images": "Preload images",
"use_one_click_nsfw": "Open NSFW attachments with just one click",
"hide_post_stats": "Hide post statistics (e.g. the number of favorites)",
"hide_user_stats": "Hide user statistics (e.g. the number of followers)",
"hide_filtered_statuses": "Hide filtered statuses",
"import_followers_from_a_csv_file": "Import follows from a csv file",
"import_theme": "Load preset",
"inputRadius": "Input fields",
"checkboxRadius": "Checkboxes",
"instance_default": "(default: {value})",
"instance_default_simple": "(default)",
"interface": "Interface",
"interfaceLanguage": "Interface language",
"invalid_theme_imported": "The selected file is not a supported Pleroma theme. No changes to your theme were made.",
"limited_availability": "Unavailable in your browser",
"links": "Links",
"lock_account_description": "Restrict your account to approved followers only",
"loop_video": "Loop videos",
"loop_video_silent_only": "Loop only videos without sound (i.e. Mastodon's \"gifs\")",
"mutes_tab": "Mutes",
"play_videos_in_modal": "Play videos directly in the media viewer",
"use_contain_fit": "Don't crop the attachment in thumbnails",
"name": "Name",
"name_bio": "Name & Bio",
"new_password": "New password",
"notification_visibility": "Types of notifications to show",
"notification_visibility_follows": "Follows",
"notification_visibility_likes": "Likes",
"notification_visibility_mentions": "Mentions",
"notification_visibility_repeats": "Repeats",
"no_rich_text_description": "Strip rich text formatting from all posts",
"no_blocks": "No blocks",
"no_mutes": "No mutes",
"hide_follows_description": "Don't show who I'm following",
"hide_followers_description": "Don't show who's following me",
"show_admin_badge": "Show Admin badge in my profile",
"show_moderator_badge": "Show Moderator badge in my profile",
"nsfw_clickthrough": "Enable clickthrough NSFW attachment hiding",
"oauth_tokens": "OAuth tokens",
"token": "Token",
"refresh_token": "Refresh Token",
"valid_until": "Valid Until",
"revoke_token": "Revoke",
"panelRadius": "Panels",
"pause_on_unfocused": "Pause streaming when tab is not focused",
"presets": "Presets",
"profile_background": "Profile Background",
"profile_banner": "Profile Banner",
"profile_tab": "Profile",
"radii_help": "Set up interface edge rounding (in pixels)",
"replies_in_timeline": "Replies in timeline",
"reply_link_preview": "Enable reply-link preview on mouse hover",
"reply_visibility_all": "Show all replies",
"reply_visibility_following": "Only show replies directed at me or users I'm following",
"reply_visibility_self": "Only show replies directed at me",
"saving_err": "Error saving settings",
"saving_ok": "Settings saved",
"security_tab": "Security",
"scope_copy": "Copy scope when replying (DMs are always copied)",
"set_new_avatar": "Set new avatar",
"set_new_profile_background": "Set new profile background",
"set_new_profile_banner": "Set new profile banner",
"settings": "Settings",
"subject_input_always_show": "Always show subject field",
"subject_line_behavior": "Copy subject when replying",
"subject_line_email": "Like email: \"re: subject\"",
"subject_line_mastodon": "Like mastodon: copy as is",
"subject_line_noop": "Do not copy",
"post_status_content_type": "Post status content type",
- "status_content_type_plain": "Plain text",
"stop_gifs": "Play-on-hover GIFs",
"streaming": "Enable automatic streaming of new posts when scrolled to the top",
"text": "Text",
"theme": "Theme",
"theme_help": "Use hex color codes (#rrggbb) to customize your color theme.",
"theme_help_v2_1": "You can also override certain component's colors and opacity by toggling the checkbox, use \"Clear all\" button to clear all overrides.",
"theme_help_v2_2": "Icons underneath some entries are background/text contrast indicators, hover over for detailed info. Please keep in mind that when using transparency contrast indicators show the worst possible case.",
"tooltipRadius": "Tooltips/alerts",
"upload_a_photo": "Upload a photo",
"user_settings": "User Settings",
"values": {
"false": "no",
"true": "yes"
},
"notifications": "Notifications",
"enable_web_push_notifications": "Enable web push notifications",
"style": {
"switcher": {
"keep_color": "Keep colors",
"keep_shadows": "Keep shadows",
"keep_opacity": "Keep opacity",
"keep_roundness": "Keep roundness",
"keep_fonts": "Keep fonts",
"save_load_hint": "\"Keep\" options preserve currently set options when selecting or loading themes, it also stores said options when exporting a theme. When all checkboxes unset, exporting theme will save everything.",
"reset": "Reset",
"clear_all": "Clear all",
"clear_opacity": "Clear opacity"
},
"common": {
"color": "Color",
"opacity": "Opacity",
"contrast": {
"hint": "Contrast ratio is {ratio}, it {level} {context}",
"level": {
"aa": "meets Level AA guideline (minimal)",
"aaa": "meets Level AAA guideline (recommended)",
"bad": "doesn't meet any accessibility guidelines"
},
"context": {
"18pt": "for large (18pt+) text",
"text": "for text"
}
}
},
"common_colors": {
"_tab_label": "Common",
"main": "Common colors",
"foreground_hint": "See \"Advanced\" tab for more detailed control",
"rgbo": "Icons, accents, badges"
},
"advanced_colors": {
"_tab_label": "Advanced",
"alert": "Alert background",
"alert_error": "Error",
"badge": "Badge background",
"badge_notification": "Notification",
"panel_header": "Panel header",
"top_bar": "Top bar",
"borders": "Borders",
"buttons": "Buttons",
"inputs": "Input fields",
"faint_text": "Faded text"
},
"radii": {
"_tab_label": "Roundness"
},
"shadows": {
"_tab_label": "Shadow and lighting",
"component": "Component",
"override": "Override",
"shadow_id": "Shadow #{value}",
"blur": "Blur",
"spread": "Spread",
"inset": "Inset",
"hint": "For shadows you can also use --variable as a color value to use CSS3 variables. Please note that setting opacity won't work in this case.",
"filter_hint": {
"always_drop_shadow": "Warning, this shadow always uses {0} when browser supports it.",
"drop_shadow_syntax": "{0} does not support {1} parameter and {2} keyword.",
"avatar_inset": "Please note that combining both inset and non-inset shadows on avatars might give unexpected results with transparent avatars.",
"spread_zero": "Shadows with spread > 0 will appear as if it was set to zero",
"inset_classic": "Inset shadows will be using {0}"
},
"components": {
"panel": "Panel",
"panelHeader": "Panel header",
"topBar": "Top bar",
"avatar": "User avatar (in profile view)",
"avatarStatus": "User avatar (in post display)",
"popup": "Popups and tooltips",
"button": "Button",
"buttonHover": "Button (hover)",
"buttonPressed": "Button (pressed)",
"buttonPressedHover": "Button (pressed+hover)",
"input": "Input field"
}
},
"fonts": {
"_tab_label": "Fonts",
"help": "Select font to use for elements of UI. For \"custom\" you have to enter exact font name as it appears in system.",
"components": {
"interface": "Interface",
"input": "Input fields",
"post": "Post text",
"postCode": "Monospaced text in a post (rich text)"
},
"family": "Font name",
"size": "Size (in px)",
"weight": "Weight (boldness)",
"custom": "Custom"
},
"preview": {
"header": "Preview",
"content": "Content",
"error": "Example error",
"button": "Button",
"text": "A bunch of more {0} and {1}",
"mono": "content",
"input": "Just landed in L.A.",
"faint_link": "helpful manual",
"fine_print": "Read our {0} to learn nothing useful!",
"header_faint": "This is fine",
"checkbox": "I have skimmed over terms and conditions",
"link": "a nice lil' link"
}
}
},
"timeline": {
"collapse": "Collapse",
"conversation": "Conversation",
"error_fetching": "Error fetching updates",
"load_older": "Load older statuses",
"no_retweet_hint": "Post is marked as followers-only or direct and cannot be repeated",
"repeated": "repeated",
"show_new": "Show new",
"up_to_date": "Up-to-date",
"no_more_statuses": "No more statuses",
"no_statuses": "No statuses"
},
"status": {
"reply_to": "Reply to",
"replies_list": "Replies:"
},
"user_card": {
"approve": "Approve",
"block": "Block",
"blocked": "Blocked!",
"deny": "Deny",
"favorites": "Favorites",
"follow": "Follow",
"follow_sent": "Request sent!",
"follow_progress": "Requesting…",
"follow_again": "Send request again?",
"follow_unfollow": "Unfollow",
"followees": "Following",
"followers": "Followers",
"following": "Following!",
"follows_you": "Follows you!",
"its_you": "It's you!",
"media": "Media",
"mute": "Mute",
"muted": "Muted",
"per_day": "per day",
"remote_follow": "Remote follow",
"statuses": "Statuses",
"unblock": "Unblock",
"unblock_progress": "Unblocking...",
"block_progress": "Blocking...",
"unmute": "Unmute",
"unmute_progress": "Unmuting...",
"mute_progress": "Muting..."
},
"user_profile": {
"timeline_title": "User Timeline",
"profile_does_not_exist": "Sorry, this profile does not exist.",
"profile_loading_error": "Sorry, there was an error loading this profile."
},
"who_to_follow": {
"more": "More",
"who_to_follow": "Who to follow"
},
"tool_tip": {
"media_upload": "Upload Media",
"repeat": "Repeat",
"reply": "Reply",
"favorite": "Favorite",
"user_settings": "User Settings"
},
"upload":{
"error": {
"base": "Upload failed.",
"file_too_big": "File too big [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]",
"default": "Try again later"
},
"file_size_units": {
"B": "B",
"KiB": "KiB",
"MiB": "MiB",
"GiB": "GiB",
"TiB": "TiB"
}
}
}
diff --git a/src/i18n/eo.json b/src/i18n/eo.json
index 2438b4d5f6..34851a4406 100644
--- a/src/i18n/eo.json
+++ b/src/i18n/eo.json
@@ -1,422 +1,421 @@
{
"chat": {
"title": "Babilejo"
},
"features_panel": {
"chat": "Babilejo",
"gopher": "Gopher",
"media_proxy": "Aŭdvidaĵa prokurilo",
"scope_options": "Agordoj de amplekso",
"text_limit": "Teksta limo",
"title": "Funkcioj",
"who_to_follow": "Kiun aboni"
},
"finder": {
"error_fetching_user": "Eraro alportante uzanton",
"find_user": "Trovi uzanton"
},
"general": {
"apply": "Apliki",
"submit": "Sendi",
"more": "Pli",
"generic_error": "Eraro okazis",
"optional": "Malnepra"
},
"image_cropper": {
"crop_picture": "Tondi bildon",
"save": "Konservi",
"cancel": "Nuligi"
},
"login": {
"login": "Saluti",
"description": "Saluti per OAuth",
"logout": "Adiaŭi",
"password": "Pasvorto",
"placeholder": "ekz. lain",
"register": "Registriĝi",
"username": "Salutnomo",
"hint": "Salutu por partopreni la diskutadon"
},
"media_modal": {
"previous": "Antaŭa",
"next": "Sekva"
},
"nav": {
"about": "Pri",
"back": "Reen",
"chat": "Loka babilejo",
"friend_requests": "Abonaj petoj",
"mentions": "Mencioj",
"dms": "Rektaj mesaĝoj",
"public_tl": "Publika tempolinio",
"timeline": "Tempolinio",
"twkn": "La tuta konata reto",
"user_search": "Serĉi uzantojn",
"who_to_follow": "Kiun aboni",
"preferences": "Agordoj"
},
"notifications": {
"broken_favorite": "Nekonata stato, serĉante ĝin…",
"favorited_you": "ŝatis vian staton",
"followed_you": "ekabonis vin",
"load_older": "Enlegi pli malnovajn sciigojn",
"notifications": "Sciigoj",
"read": "Legite!",
"repeated_you": "ripetis vian staton",
"no_more_notifications": "Neniuj pliaj sciigoj"
},
"post_status": {
"new_status": "Afiŝi novan staton",
"account_not_locked_warning": "Via konto ne estas {0}. Iu ajn povas vin aboni por vidi viajn afiŝoj nur por abonantoj.",
"account_not_locked_warning_link": "ŝlosita",
"attachments_sensitive": "Marki kunsendaĵojn kiel konsternajn",
"content_type": {
"plain_text": "Plata teksto"
},
"content_warning": "Temo (malnepra)",
"default": "Ĵus alvenis al la Universala Kongreso!",
"direct_warning": "Ĉi tiu afiŝo estos videbla nur por ĉiuj menciitaj uzantoj.",
"posting": "Afiŝante",
"scope": {
"direct": "Rekta – Afiŝi nur al menciitaj uzantoj",
"private": "Nur abonantoj – Afiŝi nur al abonantoj",
"public": "Publika – Afiŝi al publikaj tempolinioj",
"unlisted": "Nelistigita – Ne afiŝi al publikaj tempolinioj"
}
},
"registration": {
"bio": "Priskribo",
"email": "Retpoŝtadreso",
"fullname": "Vidiga nomo",
"password_confirm": "Konfirmo de pasvorto",
"registration": "Registriĝo",
"token": "Invita ĵetono",
"captcha": "TESTO DE HOMECO",
"new_captcha": "Alklaku la bildon por akiri novan teston",
"username_placeholder": "ekz. lain",
"fullname_placeholder": "ekz. Lain Iwakura",
"bio_placeholder": "ekz.\nSaluton, mi estas Lain\nMi estas animea knabino vivante en Japanujo. Eble vi konas min de la retejo « Wired ».",
"validations": {
"username_required": "ne povas resti malplena",
"fullname_required": "ne povas resti malplena",
"email_required": "ne povas resti malplena",
"password_required": "ne povas resti malplena",
"password_confirmation_required": "ne povas resti malplena",
"password_confirmation_match": "samu la pasvorton"
}
},
"settings": {
"app_name": "Nomo de aplikaĵo",
"attachmentRadius": "Kunsendaĵoj",
"attachments": "Kunsendaĵoj",
"autoload": "Ŝalti memfaran enlegadon ĉe subo de paĝo",
"avatar": "Profilbildo",
"avatarAltRadius": "Profilbildoj (sciigoj)",
"avatarRadius": "Profilbildoj",
"background": "Fono",
"bio": "Priskribo",
"blocks_tab": "Baroj",
"btnRadius": "Butonoj",
"cBlue": "Blua (Respondo, abono)",
"cGreen": "Verda (Kunhavigo)",
"cOrange": "Oranĝa (Ŝato)",
"cRed": "Ruĝa (Nuligo)",
"change_password": "Ŝanĝi pasvorton",
"change_password_error": "Okazis eraro dum ŝanĝo de via pasvorto.",
"changed_password": "Pasvorto sukcese ŝanĝiĝis!",
"collapse_subject": "Maletendi afiŝojn kun temoj",
"composing": "Verkante",
"confirm_new_password": "Konfirmu novan pasvorton",
"current_avatar": "Via nuna profilbildo",
"current_password": "Nuna pasvorto",
"current_profile_banner": "Via nuna profila rubando",
"data_import_export_tab": "Enporto / Elporto de datenoj",
"default_vis": "Implicita videbleca amplekso",
"delete_account": "Forigi konton",
"delete_account_description": "Por ĉiam forigi vian konton kaj ĉiujn viajn mesaĝojn",
"delete_account_error": "Okazis eraro dum forigo de via kanto. Se tio daŭre okazados, bonvolu kontakti la administranton de via nodo.",
"delete_account_instructions": "Entajpu sube vian pasvorton por konfirmi forigon de konto.",
"avatar_size_instruction": "La rekomendata malpleja grando de profilbildoj estas 150×150 bilderoj.",
"export_theme": "Konservi antaŭagordon",
"filtering": "Filtrado",
"filtering_explanation": "Ĉiuj statoj kun tiuj ĉi vortoj silentiĝos, po unu linio",
"follow_export": "Abona elporto",
"follow_export_button": "Elporti viajn abonojn al CSV-dosiero",
"follow_export_processing": "Traktante; baldaŭ vi ricevos peton elŝuti la dosieron",
"follow_import": "Abona enporto",
"follow_import_error": "Eraro enportante abonojn",
"follows_imported": "Abonoj enportiĝis! Traktado daŭros iom.",
"foreground": "Malfono",
"general": "Ĝenerala",
"hide_attachments_in_convo": "Kaŝi kunsendaĵojn en interparoloj",
"hide_attachments_in_tl": "Kaŝi kunsendaĵojn en tempolinio",
"max_thumbnails": "Plej multa nombro da bildetoj po afiŝo",
"hide_isp": "Kaŝi nodo-propran breton",
"preload_images": "Antaŭ-enlegi bildojn",
"use_one_click_nsfw": "Malfermi konsternajn kunsendaĵojn per nur unu klako",
"hide_post_stats": "Kaŝi statistikon de afiŝoj (ekz. nombron da ŝatoj)",
"hide_user_stats": "Kaŝi statistikon de uzantoj (ekz. nombron da abonantoj)",
"hide_filtered_statuses": "Kaŝi filtritajn statojn",
"import_followers_from_a_csv_file": "Enporti abonojn el CSV-dosiero",
"import_theme": "Enlegi antaŭagordojn",
"inputRadius": "Enigaj kampoj",
"checkboxRadius": "Markbutonoj",
"instance_default": "(implicita: {value})",
"instance_default_simple": "(implicita)",
"interface": "Fasado",
"interfaceLanguage": "Lingvo de fasado",
"invalid_theme_imported": "La elektita dosiero ne estas subtenata haŭto de Pleromo. Neniuj ŝanĝoj al via haŭto okazis.",
"limited_availability": "Nehavebla en via foliumilo",
"links": "Ligiloj",
"lock_account_description": "Limigi vian konton al nur abonantoj aprobitaj",
"loop_video": "Ripetadi filmojn",
"loop_video_silent_only": "Ripetadi nur filmojn sen sono (ekz. la \"GIF-ojn\" de Mastodon)",
"mutes_tab": "Silentigoj",
"play_videos_in_modal": "Ludi filmojn rekte en la aŭdvidaĵa spektilo",
"use_contain_fit": "Ne tondi la kunsendaĵon en bildetoj",
"name": "Nomo",
"name_bio": "Nomo kaj priskribo",
"new_password": "Nova pasvorto",
"notification_visibility": "Montrotaj specoj de sciigoj",
"notification_visibility_follows": "Abonoj",
"notification_visibility_likes": "Ŝatoj",
"notification_visibility_mentions": "Mencioj",
"notification_visibility_repeats": "Ripetoj",
"no_rich_text_description": "Forigi riĉtekstajn formojn de ĉiuj afiŝoj",
"no_blocks": "Neniuj baroj",
"no_mutes": "Neniuj silentigoj",
"hide_follows_description": "Ne montri kiun mi sekvas",
"hide_followers_description": "Ne montri kiu min sekvas",
"show_admin_badge": "Montri la insignon de administranto en mia profilo",
"show_moderator_badge": "Montri la insignon de kontrolanto en mia profilo",
"nsfw_clickthrough": "Ŝalti traklakan kaŝon de konsternaj kunsendaĵoj",
"oauth_tokens": "Ĵetonoj de OAuth",
"token": "Ĵetono",
"refresh_token": "Ĵetono de novigo",
"valid_until": "Valida ĝis",
"revoke_token": "Senvalidigi",
"panelRadius": "Bretoj",
"pause_on_unfocused": "Paŭzigi elsendfluon kiam langeto ne estas fokusata",
"presets": "Antaŭagordoj",
"profile_background": "Profila fono",
"profile_banner": "Profila rubando",
"profile_tab": "Profilo",
"radii_help": "Agordi fasadan rondigon de randoj (bildere)",
"replies_in_timeline": "Respondoj en tempolinio",
"reply_link_preview": "Ŝalti respond-ligilan antaŭvidon dum musa ŝvebo",
"reply_visibility_all": "Montri ĉiujn respondojn",
"reply_visibility_following": "Montri nur respondojn por mi aŭ miaj abonatoj",
"reply_visibility_self": "Montri nur respondojn por mi",
"saving_err": "Eraro dum konservo de agordoj",
"saving_ok": "Agordoj konserviĝis",
"security_tab": "Sekureco",
"scope_copy": "Kopii amplekson por respondo (rektaj mesaĝoj ĉiam kopiiĝas)",
"set_new_avatar": "Agordi novan profilbildon",
"set_new_profile_background": "Agordi novan profilan fonon",
"set_new_profile_banner": "Agordi novan profilan rubandon",
"settings": "Agordoj",
"subject_input_always_show": "Ĉiam montri teman kampon",
"subject_line_behavior": "Kopii temon por respondo",
"subject_line_email": "Kiel retpoŝto: \"re: temo\"",
"subject_line_mastodon": "Kiel Mastodon: kopii senŝanĝe",
"subject_line_noop": "Ne kopii",
"post_status_content_type": "Afiŝi specon de la enhavo de la stato",
- "status_content_type_plain": "Plata teksto",
"stop_gifs": "Movi GIF-bildojn dum musa ŝvebo",
"streaming": "Ŝalti memfaran fluigon de novaj afiŝoj ĉe la supro de la paĝo",
"text": "Teksto",
"theme": "Haŭto",
"theme_help": "Uzu deksesumajn kolorkodojn (#rrvvbb) por adapti vian koloran haŭton.",
"theme_help_v2_1": "Vi ankaŭ povas superagordi la kolorojn kaj travideblecon de kelkaj eroj per marko de la markbutono; uzu la butonon \"Vakigi ĉion\" por forigi ĉîujn superagordojn.",
"theme_help_v2_2": "Bildsimboloj sub kelkaj eroj estas indikiloj de kontrasto inter fono kaj teksto; muse ŝvebu por detalaj informoj. Bonvolu memori, ke la indikilo montras la plej malbonan okazeblon dum sia uzo.",
"tooltipRadius": "Ŝpruchelpiloj/avertoj",
"upload_a_photo": "Alŝuti foton",
"user_settings": "Agordoj de uzanto",
"values": {
"false": "ne",
"true": "jes"
},
"notifications": "Sciigoj",
"enable_web_push_notifications": "Ŝalti retajn puŝajn sciigojn",
"style": {
"switcher": {
"keep_color": "Konservi kolorojn",
"keep_shadows": "Konservi ombrojn",
"keep_opacity": "Konservi maltravideblecon",
"keep_roundness": "Konservi rondecon",
"keep_fonts": "Konservi tiparojn",
"save_load_hint": "Elektebloj de \"konservi\" konservas la nuntempajn agordojn dum elektado aŭ enlegado de haŭtoj. Ĝi ankaŭ konservas tiujn agordojn dum elportado de haŭto. Kun ĉiuj markbutonoj nemarkitaj, elporto de la haŭto ĉion konservos.",
"reset": "Restarigi",
"clear_all": "Vakigi ĉion",
"clear_opacity": "Vakigi maltravideblecon"
},
"common": {
"color": "Koloro",
"opacity": "Maltravidebleco",
"contrast": {
"hint": "Proporcio de kontrasto estas {ratio}, ĝi {level} {context}",
"level": {
"aa": "plenumas la gvidilon je nivelo AA (malpleja)",
"aaa": "plenumas la gvidilon je nivela AAA (rekomendita)",
"bad": "plenumas neniujn faciluzajn gvidilojn"
},
"context": {
"18pt": "por granda (18pt+) teksto",
"text": "por teksto"
}
}
},
"common_colors": {
"_tab_label": "Komunaj",
"main": "Komunaj koloroj",
"foreground_hint": "Vidu langeton \"Specialaj\" por pli detalaj agordoj",
"rgbo": "Bildsimboloj, emfazoj, insignoj"
},
"advanced_colors": {
"_tab_label": "Specialaj",
"alert": "Averta fono",
"alert_error": "Eraro",
"badge": "Insigna fono",
"badge_notification": "Sciigo",
"panel_header": "Kapo de breto",
"top_bar": "Supra breto",
"borders": "Limoj",
"buttons": "Butonoj",
"inputs": "Enigaj kampoj",
"faint_text": "Malvigla teksto"
},
"radii": {
"_tab_label": "Rondeco"
},
"shadows": {
"_tab_label": "Ombro kaj lumo",
"component": "Ero",
"override": "Transpasi",
"shadow_id": "Ombro #{value}",
"blur": "Malklarigo",
"spread": "Vastigo",
"inset": "Internigo",
"hint": "Por ombroj vi ankaŭ povas uzi --variable kiel koloran valoron, por uzi variantojn de CSS3. Bonvolu rimarki, ke tiuokaze agordoj de maltravidebleco ne funkcios.",
"filter_hint": {
"always_drop_shadow": "Averto: ĉi tiu ombro ĉiam uzas {0} kiam la foliumilo ĝin subtenas.",
"drop_shadow_syntax": "{0} ne subtenas parametron {1} kaj ŝlosilvorton {2}.",
"avatar_inset": "Bonvolu rimarki, ke agordi ambaŭ internajn kaj eksterajn ombrojn por profilbildoj povas redoni neatenditajn rezultojn ĉe profilbildoj travideblaj.",
"spread_zero": "Ombroj kun vastigo > 0 aperos kvazaŭ ĝi estus fakte nulo",
"inset_classic": "Internaj ombroj uzos {0}"
},
"components": {
"panel": "Breto",
"panelHeader": "Kapo de breto",
"topBar": "Supra breto",
"avatar": "Profilbildo de uzanto (en profila vido)",
"avatarStatus": "Profilbildo de uzanto (en afiŝa vido)",
"popup": "Ŝprucaĵoj",
"button": "Butono",
"buttonHover": "Butono (je ŝvebo)",
"buttonPressed": "Butono (premita)",
"buttonPressedHover": "Butono (premita je ŝvebo)",
"input": "Eniga kampo"
}
},
"fonts": {
"_tab_label": "Tiparoj",
"help": "Elektu tiparon uzotan por eroj de la fasado. Por \"propra\" vi devas enigi la precizan nomon de tiparo tiel, kiel ĝi aperas en la sistemo",
"components": {
"interface": "Fasado",
"input": "Enigaj kampoj",
"post": "Teksto de afiŝo",
"postCode": "Egallarĝa teksto en afiŝo (riĉteksto)"
},
"family": "Nomo de tiparo",
"size": "Grando (en bilderoj)",
"weight": "Pezo (graseco)",
"custom": "Propra"
},
"preview": {
"header": "Antaŭrigardo",
"content": "Enhavo",
"error": "Ekzempla eraro",
"button": "Butono",
"text": "Kelko da pliaj {0} kaj {1}",
"mono": "enhavo",
"input": "Ĵus alvenis al la Universala Kongreso!",
"faint_link": "helpan manlibron",
"fine_print": "Legu nian {0} por nenion utilan ekscii!",
"header_faint": "Tio estas en ordo",
"checkbox": "Mi legetis la kondiĉojn de uzado",
"link": "bela eta ligil’"
}
}
},
"timeline": {
"collapse": "Maletendi",
"conversation": "Interparolo",
"error_fetching": "Eraro dum ĝisdatigo",
"load_older": "Montri pli malnovajn statojn",
"no_retweet_hint": "Afiŝo estas markita kiel rekta aŭ nur por abonantoj, kaj ne eblas ĝin ripeti",
"repeated": "ripetita",
"show_new": "Montri novajn",
"up_to_date": "Ĝisdata",
"no_more_statuses": "Neniuj pliaj statoj",
"no_statuses": "Neniuj statoj"
},
"user_card": {
"approve": "Aprobi",
"block": "Bari",
"blocked": "Barita!",
"deny": "Rifuzi",
"favorites": "Ŝatataj",
"follow": "Aboni",
"follow_sent": "Peto sendiĝis!",
"follow_progress": "Petanta…",
"follow_again": "Ĉu sendi peton denove?",
"follow_unfollow": "Malaboni",
"followees": "Abonatoj",
"followers": "Abonantoj",
"following": "Abonanta!",
"follows_you": "Abonas vin!",
"its_you": "Tio estas vi!",
"media": "Aŭdvidaĵoj",
"mute": "Silentigi",
"muted": "Silentigitaj",
"per_day": "tage",
"remote_follow": "Fore aboni",
"statuses": "Statoj",
"unblock": "Malbari",
"unblock_progress": "Malbaranta…",
"block_progress": "Baranta…",
"unmute": "Malsilentigi",
"unmute_progress": "Malsilentiganta…",
"mute_progress": "Silentiganta…"
},
"user_profile": {
"timeline_title": "Uzanta tempolinio",
"profile_does_not_exist": "Pardonu, ĉi tiu profilo ne ekzistas.",
"profile_loading_error": "Pardonu, eraro okazis dum enlegado de ĉi tiu profilo."
},
"who_to_follow": {
"more": "Pli",
"who_to_follow": "Kiun aboni"
},
"tool_tip": {
"media_upload": "Alŝuti aŭdvidaĵon",
"repeat": "Ripeti",
"reply": "Respondi",
"favorite": "Ŝati",
"user_settings": "Agordoj de uzanto"
},
"upload":{
"error": {
"base": "Alŝuto malsukcesis.",
"file_too_big": "Dosiero estas tro granda [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]",
"default": "Reprovu pli poste"
},
"file_size_units": {
"B": "B",
"KiB": "KiB",
"MiB": "MiB",
"GiB": "GiB",
"TiB": "TiB"
}
}
}
diff --git a/src/i18n/es.json b/src/i18n/es.json
index 167e8c4288..fe96dd083a 100644
--- a/src/i18n/es.json
+++ b/src/i18n/es.json
@@ -1,393 +1,392 @@
{
"chat": {
"title": "Chat"
},
"features_panel": {
"chat": "Chat",
"gopher": "Gopher",
"media_proxy": "Media proxy",
"scope_options": "Opciones del alcance de la visibilidad",
"text_limit": "Límite de carácteres",
"title": "Características",
"who_to_follow": "A quién seguir"
},
"finder": {
"error_fetching_user": "Error al buscar usuario",
"find_user": "Encontrar usuario"
},
"general": {
"apply": "Aplicar",
"submit": "Enviar",
"more": "Más",
"generic_error": "Ha ocurrido un error"
},
"login": {
"login": "Identificación",
"description": "Identificación con OAuth",
"logout": "Salir",
"password": "Contraseña",
"placeholder": "p.ej. lain",
"register": "Registrar",
"username": "Usuario",
"hint": "Inicia sesión para unirte a la discusión"
},
"nav": {
"about": "Sobre",
"back": "Volver",
"chat": "Chat Local",
"friend_requests": "Solicitudes de amistad",
"mentions": "Menciones",
"dms": "Mensajes Directo",
"public_tl": "Línea Temporal Pública",
"timeline": "Línea Temporal",
"twkn": "Toda La Red Conocida",
"user_search": "Búsqueda de Usuarios",
"who_to_follow": "A quién seguir",
"preferences": "Preferencias"
},
"notifications": {
"broken_favorite": "Estado desconocido, buscándolo...",
"favorited_you": "le gusta tu estado",
"followed_you": "empezó a seguirte",
"load_older": "Cargar notificaciones antiguas",
"notifications": "Notificaciones",
"read": "¡Leído!",
"repeated_you": "repite tu estado",
"no_more_notifications": "No hay más notificaciones"
},
"post_status": {
"new_status": "Publicar un nuevo estado",
"account_not_locked_warning": "Tu cuenta no está {0}. Cualquiera puede seguirte y leer las entradas para Solo-Seguidores.",
"account_not_locked_warning_link": "bloqueada",
"attachments_sensitive": "Contenido sensible",
"content_type": {
"plain_text": "Texto Plano"
},
"content_warning": "Tema (opcional)",
"default": "Acabo de aterrizar en L.A.",
"direct_warning": "Esta entrada solo será visible para los usuarios mencionados.",
"posting": "Publicando",
"scope": {
"direct": "Directo - Solo para los usuarios mencionados.",
"private": "Solo-Seguidores - Solo tus seguidores leeran la entrada",
"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"
}
},
"registration": {
"bio": "Biografía",
"email": "Correo electrónico",
"fullname": "Nombre a mostrar",
"password_confirm": "Confirmación de contraseña",
"registration": "Registro",
"token": "Token de invitación",
"captcha": "CAPTCHA",
"new_captcha": "Click en la imagen para obtener un nuevo captca",
"validations": {
"username_required": "no puede estar vacío",
"fullname_required": "no puede estar vacío",
"email_required": "no puede estar vacío",
"password_required": "no puede estar vacío",
"password_confirmation_required": "no puede estar vacío",
"password_confirmation_match": "la contraseña no coincide"
}
},
"settings": {
"attachmentRadius": "Adjuntos",
"attachments": "Adjuntos",
"autoload": "Activar carga automática al llegar al final de la página",
"avatar": "Avatar",
"avatarAltRadius": "Avatares (Notificaciones)",
"avatarRadius": "Avatares",
"background": "Fondo",
"bio": "Biografía",
"btnRadius": "Botones",
"cBlue": "Azul (Responder, seguir)",
"cGreen": "Verde (Retweet)",
"cOrange": "Naranja (Favorito)",
"cRed": "Rojo (Cancelar)",
"change_password": "Cambiar contraseña",
"change_password_error": "Hubo un problema cambiando la contraseña.",
"changed_password": "Contraseña cambiada correctamente!",
"collapse_subject": "Colapsar entradas con tema",
"composing": "Redactando",
"confirm_new_password": "Confirmar la nueva contraseña",
"current_avatar": "Tu avatar actual",
"current_password": "Contraseña actual",
"current_profile_banner": "Tu cabecera actual",
"data_import_export_tab": "Importar / Exportar Datos",
"default_vis": "Alcance de visibilidad por defecto",
"delete_account": "Eliminar la cuenta",
"delete_account_description": "Eliminar para siempre la cuenta y todos los mensajes.",
"delete_account_error": "Hubo un error al eliminar tu cuenta. Si el fallo persiste, ponte en contacto con el administrador de tu instancia.",
"delete_account_instructions": "Escribe tu contraseña para confirmar la eliminación de tu cuenta.",
"avatar_size_instruction": "El tamaño mínimo recomendado para el avatar es de 150X150 píxeles.",
"export_theme": "Exportar tema",
"filtering": "Filtros",
"filtering_explanation": "Todos los estados que contengan estas palabras serán silenciados, una por línea",
"follow_export": "Exportar personas que tú sigues",
"follow_export_button": "Exporta tus seguidores a un archivo csv",
"follow_export_processing": "Procesando, en breve se te preguntará para guardar el archivo",
"follow_import": "Importar personas que tú sigues",
"follow_import_error": "Error al importal el archivo",
"follows_imported": "¡Importado! Procesarlos llevará tiempo.",
"foreground": "Primer plano",
"general": "General",
"hide_attachments_in_convo": "Ocultar adjuntos en las conversaciones",
"hide_attachments_in_tl": "Ocultar adjuntos en la línea temporal",
"hide_isp": "Ocultar el panel específico de la instancia",
"preload_images": "Precargar las imágenes",
"use_one_click_nsfw": "Abrir los adjuntos NSFW con un solo click.",
"hide_post_stats": "Ocultar las estadísticas de las entradas (p.ej. el número de favoritos)",
"hide_user_stats": "Ocultar las estadísticas del usuario (p.ej. el número de seguidores)",
"hide_filtered_statuses": "Ocultar estados filtrados",
"import_followers_from_a_csv_file": "Importar personas que tú sigues a partir de un archivo csv",
"import_theme": "Importar tema",
"inputRadius": "Campos de entrada",
"checkboxRadius": "Casillas de verificación",
"instance_default": "(por defecto: {value})",
"instance_default_simple": "(por defecto)",
"interface": "Interfaz",
"interfaceLanguage": "Idioma",
"invalid_theme_imported": "El archivo importado no es un tema válido de Pleroma. No se han realizado cambios.",
"limited_availability": "No disponible en tu navegador",
"links": "Enlaces",
"lock_account_description": "Restringir el acceso a tu cuenta solo a seguidores admitidos",
"loop_video": "Vídeos en bucle",
"loop_video_silent_only": "Bucle solo en vídeos sin sonido (p.ej. \"gifs\" de Mastodon)",
"play_videos_in_modal": "Reproducir los vídeos directamente en el visor de medios",
"use_contain_fit": "No recortar los adjuntos en miniaturas",
"name": "Nombre",
"name_bio": "Nombre y Biografía",
"new_password": "Nueva contraseña",
"notification_visibility": "Tipos de notificaciones a mostrar",
"notification_visibility_follows": "Nuevos seguidores",
"notification_visibility_likes": "Me gustan (Likes)",
"notification_visibility_mentions": "Menciones",
"notification_visibility_repeats": "Repeticiones (Repeats)",
"no_rich_text_description": "Eliminar el formato de texto enriquecido de todas las entradas",
"hide_follows_description": "No mostrar a quién sigo",
"hide_followers_description": "No mostrar quién me sigue",
"show_admin_badge": "Mostrar la placa de administrador en mi perfil",
"show_moderator_badge": "Mostrar la placa de moderador en mi perfil",
"nsfw_clickthrough": "Activar el clic para ocultar los adjuntos NSFW",
"oauth_tokens": "Tokens de OAuth",
"token": "Token",
"refresh_token": "Actualizar el token",
"valid_until": "Válido hasta",
"revoke_token": "Revocar",
"panelRadius": "Paneles",
"pause_on_unfocused": "Parar la transmisión cuando no estés en foco.",
"presets": "Por defecto",
"profile_background": "Fondo del Perfil",
"profile_banner": "Cabecera del Perfil",
"profile_tab": "Perfil",
"radii_help": "Estable el redondeo de las esquinas del 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 encim",
"reply_visibility_all": "Mostrar todas las réplicas",
"reply_visibility_following": "Solo mostrar réplicas para mí o usuarios a los que sigo",
"reply_visibility_self": "Solo mostrar réplicas para mí",
"saving_err": "Error al guardar los ajustes",
"saving_ok": "Ajustes guardados",
"security_tab": "Seguridad",
"scope_copy": "Copiar la visibilidad cuando contestamos (En los mensajes directos (MDs) siempre se copia)",
"set_new_avatar": "Cambiar avatar",
"set_new_profile_background": "Cambiar fondo del perfil",
"set_new_profile_banner": "Cambiar cabecera del perfil",
"settings": "Ajustes",
"subject_input_always_show": "Mostrar siempre el campo del tema",
"subject_line_behavior": "Copiar el tema en las contestaciones",
"subject_line_email": "Tipo email: \"re: tema\"",
"subject_line_mastodon": "Tipo mastodon: copiar como es",
"subject_line_noop": "No copiar",
"post_status_content_type": "Formato de publicación",
- "status_content_type_plain": "Texto plano",
"stop_gifs": "Iniciar GIFs al pasar el ratón",
"streaming": "Habilite la transmisión automática de nuevas publicaciones cuando se desplaza hacia la parte superior",
"text": "Texto",
"theme": "Tema",
"theme_help": "Use códigos de color hexadecimales (#rrggbb) para personalizar su tema de colores.",
"theme_help_v2_1": "También puede invalidar los colores y la opacidad de ciertos componentes si activa la casilla de verificación, use el botón \"Borrar todo\" para deshacer los cambios.",
"theme_help_v2_2": "Los iconos debajo de algunas entradas son indicadores de contraste de fondo/texto, desplace el ratón para obtener información detallada. Tenga en cuenta que cuando se utilizan indicadores de contraste de transparencia se muestra el peor caso posible.",
"tooltipRadius": "Información/alertas",
"user_settings": "Ajustes de Usuario",
"values": {
"false": "no",
"true": "sí"
},
"notifications": "Notificaciones",
"enable_web_push_notifications": "Habilitar las notificiaciones en el navegador",
"style": {
"switcher": {
"keep_color": "Mantener colores",
"keep_shadows": "Mantener sombras",
"keep_opacity": "Mantener opacidad",
"keep_roundness": "Mantener redondeces",
"keep_fonts": "Mantener fuentes",
"save_load_hint": "Las opciones \"Mantener\" conservan las opciones configuradas actualmente al seleccionar o cargar temas, también almacena dichas opciones al exportar un tema. Cuando se desactiven todas las casillas de verificación, el tema de exportación lo guardará todo.",
"reset": "Reiniciar",
"clear_all": "Limpiar todo",
"clear_opacity": "Limpiar opacidad"
},
"common": {
"color": "Color",
"opacity": "Opacidad",
"contrast": {
"hint": "El ratio de contraste es {ratio}. {level} {context}",
"level": {
"aa": "Cumple con la pauta de nivel AA (mínimo)",
"aaa": "Cumple con la pauta de nivel AAA (recomendado)",
"bad": "No cumple con las pautas de accesibilidad"
},
"context": {
"18pt": "para textos grandes (+18pt)",
"text": "para textos"
}
}
},
"common_colors": {
"_tab_label": "Común",
"main": "Colores comunes",
"foreground_hint": "Vea la pestaña \"Avanzado\" para un control más detallado",
"rgbo": "Iconos, acentos, insignias"
},
"advanced_colors": {
"_tab_label": "Avanzado",
"alert": "Fondo de Alertas",
"alert_error": "Error",
"badge": "Fondo de Insignias",
"badge_notification": "Notificaciones",
"panel_header": "Cabecera del panel",
"top_bar": "Barra superior",
"borders": "Bordes",
"buttons": "Botones",
"inputs": "Campos de entrada",
"faint_text": "Texto desvanecido"
},
"radii": {
"_tab_label": "Redondez"
},
"shadows": {
"_tab_label": "Sombra e iluminación",
"component": "Componente",
"override": "Sobreescribir",
"shadow_id": "Sombra #{value}",
"blur": "Difuminar",
"spread": "Cantidad",
"inset": "Insertada",
"hint": "Para las sombras, también puede usar --variable como un valor de color para usar las variables CSS3. Tenga en cuenta que establecer la opacidad no funcionará en este caso.",
"filter_hint": {
"always_drop_shadow": "Advertencia, esta sombra siempre usa {0} cuando el navegador lo soporta.",
"drop_shadow_syntax": "{0} no soporta el parámetro {1} y la palabra clave {2}.",
"avatar_inset": "Tenga en cuenta que la combinación de sombras insertadas como no-insertadas en los avatares, puede dar resultados inesperados con los avatares transparentes.",
"spread_zero": "Sombras con una cantidad > 0 aparecerá como si estuviera puesto a cero",
"inset_classic": "Las sombras insertadas estarán usando {0}"
},
"components": {
"panel": "Panel",
"panelHeader": "Cabecera del panel",
"topBar": "Barra superior",
"avatar": "Avatar del usuario (en la vista del perfil)",
"avatarStatus": "Avatar del usuario (en la vista de la entrada)",
"popup": "Ventanas y textos emergentes (popups & tooltips)",
"button": "Botones",
"buttonHover": "Botón (encima)",
"buttonPressed": "Botón (presionado)",
"buttonPressedHover": "Botón (presionado+encima)",
"input": "Campo de entrada"
}
},
"fonts": {
"_tab_label": "Fuentes",
"help": "Seleccione la fuente para utilizar para los elementos de la interfaz de usuario. Para \"personalizado\", debe ingresar el nombre exacto de la fuente tal como aparece en el sistema.",
"components": {
"interface": "Interfaz",
"input": "Campos de entrada",
"post": "Texto de publicaciones",
"postCode": "Texto monoespaciado en publicación (texto enriquecido)"
},
"family": "Nombre de la fuente",
"size": "Tamaño (en px)",
"weight": "Peso (negrita)",
"custom": "Personalizado"
},
"preview": {
"header": "Vista previa",
"content": "Contenido",
"error": "Ejemplo de error",
"button": "Botón",
"text": "Un montón de {0} y {1}",
"mono": "contenido",
"input": "Acaba de aterrizar en L.A.",
"faint_link": "manual útil",
"fine_print": "¡Lea nuestro {0} para aprender nada útil!",
"header_faint": "Esto está bien",
"checkbox": "He revisado los términos y condiciones",
"link": "un bonito enlace"
}
}
},
"timeline": {
"collapse": "Colapsar",
"conversation": "Conversación",
"error_fetching": "Error al cargar las actualizaciones",
"load_older": "Cargar actualizaciones anteriores",
"no_retweet_hint": "La publicación está marcada como solo para seguidores o directa y no se puede repetir",
"repeated": "repetida",
"show_new": "Mostrar lo nuevo",
"up_to_date": "Actualizado",
"no_more_statuses": "No hay más estados"
},
"user_card": {
"approve": "Aprovar",
"block": "Bloquear",
"blocked": "¡Bloqueado!",
"deny": "Denegar",
"favorites": "Favoritos",
"follow": "Seguir",
"follow_sent": "¡Solicitud enviada!",
"follow_progress": "Solicitando…",
"follow_again": "¿Enviar solicitud de nuevo?",
"follow_unfollow": "Dejar de seguir",
"followees": "Siguiendo",
"followers": "Seguidores",
"following": "¡Siguiendo!",
"follows_you": "¡Te sigue!",
"its_you": "¡Eres tú!",
"media": "Media",
"mute": "Silenciar",
"muted": "Silenciado",
"per_day": "por día",
"remote_follow": "Seguir",
"statuses": "Estados"
},
"user_profile": {
"timeline_title": "Linea temporal del usuario"
},
"who_to_follow": {
"more": "Más",
"who_to_follow": "A quién seguir"
},
"tool_tip": {
"media_upload": "Subir Medios",
"repeat": "Repetir",
"reply": "Contestar",
"favorite": "Favorito",
"user_settings": "Ajustes de usuario"
},
"upload":{
"error": {
"base": "Subida fallida.",
"file_too_big": "Archivo demasiado grande [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]",
"default": "Inténtalo más tarde"
},
"file_size_units": {
"B": "B",
"KiB": "KiB",
"MiB": "MiB",
"GiB": "GiB",
"TiB": "TiB"
}
}
}
diff --git a/src/i18n/ja.json b/src/i18n/ja.json
index b51fa7fdc7..f39a5a7c93 100644
--- a/src/i18n/ja.json
+++ b/src/i18n/ja.json
@@ -1,393 +1,392 @@
{
"chat": {
"title": "チャット"
},
"features_panel": {
"chat": "チャット",
"gopher": "Gopher",
"media_proxy": "メディアプロクシ",
"scope_options": "こうかいはんいせんたく",
"text_limit": "もじのかず",
"title": "ゆうこうなきのう",
"who_to_follow": "おすすめユーザー"
},
"finder": {
"error_fetching_user": "ユーザーけんさくがエラーになりました。",
"find_user": "ユーザーをさがす"
},
"general": {
"apply": "てきよう",
"submit": "そうしん",
"more": "つづき",
"generic_error": "エラーになりました"
},
"login": {
"login": "ログイン",
"description": "OAuthでログイン",
"logout": "ログアウト",
"password": "パスワード",
"placeholder": "れい: lain",
"register": "はじめる",
"username": "ユーザーめい",
"hint": "はなしあいにくわわるには、ログインしてください"
},
"nav": {
"about": "これはなに?",
"back": "もどる",
"chat": "ローカルチャット",
"friend_requests": "フォローリクエスト",
"mentions": "メンション",
"dms": "ダイレクトメッセージ",
"public_tl": "パブリックタイムライン",
"timeline": "タイムライン",
"twkn": "つながっているすべてのネットワーク",
"user_search": "ユーザーをさがす",
"who_to_follow": "おすすめユーザー",
"preferences": "せってい"
},
"notifications": {
"broken_favorite": "ステータスがみつかりません。さがしています...",
"favorited_you": "あなたのステータスがおきにいりされました",
"followed_you": "フォローされました",
"load_older": "ふるいつうちをみる",
"notifications": "つうち",
"read": "よんだ!",
"repeated_you": "あなたのステータスがリピートされました",
"no_more_notifications": "つうちはありません"
},
"post_status": {
"new_status": "とうこうする",
"account_not_locked_warning": "あなたのアカウントは {0} ではありません。あなたをフォローすれば、だれでも、フォロワーげんていのステータスをよむことができます。",
"account_not_locked_warning_link": "ロックされたアカウント",
"attachments_sensitive": "ファイルをNSFWにする",
"content_type": {
"plain_text": "プレーンテキスト"
},
"content_warning": "せつめい (かかなくてもよい)",
"default": "はねだくうこうに、つきました。",
"direct_warning": "このステータスは、メンションされたユーザーだけが、よむことができます。",
"posting": "とうこう",
"scope": {
"direct": "ダイレクト: メンションされたユーザーのみにとどきます。",
"private": "フォロワーげんてい: フォロワーのみにとどきます。",
"public": "パブリック: パブリックタイムラインにとどきます。",
"unlisted": "アンリステッド: パブリックタイムラインにとどきません。"
}
},
"registration": {
"bio": "プロフィール",
"email": "Eメール",
"fullname": "スクリーンネーム",
"password_confirm": "パスワードのかくにん",
"registration": "はじめる",
"token": "しょうたいトークン",
"captcha": "CAPTCHA",
"new_captcha": "もじがよめないときは、がぞうをクリックすると、あたらしいがぞうになります",
"validations": {
"username_required": "なにかかいてください",
"fullname_required": "なにかかいてください",
"email_required": "なにかかいてください",
"password_required": "なにかかいてください",
"password_confirmation_required": "なにかかいてください",
"password_confirmation_match": "パスワードがちがいます"
}
},
"settings": {
"attachmentRadius": "ファイル",
"attachments": "ファイル",
"autoload": "したにスクロールしたとき、じどうてきによみこむ。",
"avatar": "アバター",
"avatarAltRadius": "つうちのアバター",
"avatarRadius": "アバター",
"background": "バックグラウンド",
"bio": "プロフィール",
"btnRadius": "ボタン",
"cBlue": "リプライとフォロー",
"cGreen": "リピート",
"cOrange": "おきにいり",
"cRed": "キャンセル",
"change_password": "パスワードをかえる",
"change_password_error": "パスワードをかえることが、できなかったかもしれません。",
"changed_password": "パスワードが、かわりました!",
"collapse_subject": "せつめいのあるとうこうをたたむ",
"composing": "とうこう",
"confirm_new_password": "あたらしいパスワードのかくにん",
"current_avatar": "いまのアバター",
"current_password": "いまのパスワード",
"current_profile_banner": "いまのプロフィールバナー",
"data_import_export_tab": "インポートとエクスポート",
"default_vis": "デフォルトのこうかいはんい",
"delete_account": "アカウントをけす",
"delete_account_description": "あなたのアカウントとメッセージが、きえます。",
"delete_account_error": "アカウントをけすことが、できなかったかもしれません。インスタンスのかんりしゃに、れんらくしてください。",
"delete_account_instructions": "ほんとうにアカウントをけしてもいいなら、パスワードをかいてください。",
"avatar_size_instruction": "アバターのおおきさは、150×150ピクセルか、それよりもおおきくするといいです。",
"export_theme": "セーブ",
"filtering": "フィルタリング",
"filtering_explanation": "これらのことばをふくむすべてのものがミュートされます。1ぎょうに1つのことばをかいてください。",
"follow_export": "フォローのエクスポート",
"follow_export_button": "エクスポート",
"follow_export_processing": "おまちください。まもなくファイルをダウンロードできます。",
"follow_import": "フォローインポート",
"follow_import_error": "フォローのインポートがエラーになりました。",
"follows_imported": "フォローがインポートされました! すこしじかんがかかるかもしれません。",
"foreground": "フォアグラウンド",
"general": "ぜんぱん",
"hide_attachments_in_convo": "スレッドのファイルをかくす",
"hide_attachments_in_tl": "タイムラインのファイルをかくす",
"hide_isp": "インスタンススペシフィックパネルをかくす",
"preload_images": "がぞうをさきよみする",
"use_one_click_nsfw": "NSFWなファイルを1クリックでひらく",
"hide_post_stats": "とうこうのとうけいをかくす (れい: おきにいりのかず)",
"hide_user_stats": "ユーザーのとうけいをかくす (れい: フォロワーのかず)",
"hide_filtered_statuses": "フィルターされたとうこうをかくす",
"import_followers_from_a_csv_file": "CSVファイルからフォローをインポートする",
"import_theme": "ロード",
"inputRadius": "インプットフィールド",
"checkboxRadius": "チェックボックス",
"instance_default": "(デフォルト: {value})",
"instance_default_simple": "(デフォルト)",
"interface": "インターフェース",
"interfaceLanguage": "インターフェースのことば",
"invalid_theme_imported": "このファイルはPleromaのテーマではありません。テーマはへんこうされませんでした。",
"limited_availability": "あなたのブラウザではできません",
"links": "リンク",
"lock_account_description": "あなたがみとめたひとだけ、あなたのアカウントをフォローできる",
"loop_video": "ビデオをくりかえす",
"loop_video_silent_only": "おとのないビデオだけくりかえす",
"play_videos_in_modal": "ビデオをメディアビューアーでみる",
"use_contain_fit": "がぞうのサムネイルを、きりぬかない",
"name": "なまえ",
"name_bio": "なまえとプロフィール",
"new_password": "あたらしいパスワード",
"notification_visibility": "ひょうじするつうち",
"notification_visibility_follows": "フォロー",
"notification_visibility_likes": "おきにいり",
"notification_visibility_mentions": "メンション",
"notification_visibility_repeats": "リピート",
"no_rich_text_description": "リッチテキストをつかわない",
"hide_follows_description": "フォローしているひとをみせない",
"hide_followers_description": "フォロワーをみせない",
"show_admin_badge": "アドミンのしるしをみる",
"show_moderator_badge": "モデレーターのしるしをみる",
"nsfw_clickthrough": "NSFWなファイルをかくす",
"oauth_tokens": "OAuthトークン",
"token": "トークン",
"refresh_token": "トークンを更新",
"valid_until": "まで有効",
"revoke_token": "取り消す",
"panelRadius": "パネル",
"pause_on_unfocused": "タブにフォーカスがないときストリーミングをとめる",
"presets": "プリセット",
"profile_background": "プロフィールのバックグラウンド",
"profile_banner": "プロフィールバナー",
"profile_tab": "プロフィール",
"radii_help": "インターフェースのまるさをせっていする。",
"replies_in_timeline": "タイムラインのリプライ",
"reply_link_preview": "カーソルをかさねたとき、リプライのプレビューをみる",
"reply_visibility_all": "すべてのリプライをみる",
"reply_visibility_following": "わたしにあてられたリプライと、フォローしているひとからのリプライをみる",
"reply_visibility_self": "わたしにあてられたリプライをみる",
"saving_err": "せっていをセーブできませんでした",
"saving_ok": "せっていをセーブしました",
"security_tab": "セキュリティ",
"scope_copy": "リプライするとき、こうかいはんいをコピーする (DMのこうかいはんいは、つねにコピーされます)",
"set_new_avatar": "あたらしいアバターをせっていする",
"set_new_profile_background": "あたらしいプロフィールのバックグラウンドをせっていする",
"set_new_profile_banner": "あたらしいプロフィールバナーを設定する",
"settings": "せってい",
"subject_input_always_show": "サブジェクトフィールドをいつでもひょうじする",
"subject_line_behavior": "リプライするときサブジェクトをコピーする",
"subject_line_email": "メールふう: \"re: サブジェクト\"",
"subject_line_mastodon": "マストドンふう: そのままコピー",
"subject_line_noop": "コピーしない",
"post_status_content_type": "とうこうのコンテントタイプ",
- "status_content_type_plain": "プレーンテキスト",
"stop_gifs": "カーソルをかさねたとき、GIFをうごかす",
"streaming": "うえまでスクロールしたとき、じどうてきにストリーミングする",
"text": "もじ",
"theme": "テーマ",
"theme_help": "カラーテーマをカスタマイズできます",
"theme_help_v2_1": "チェックボックスをONにすると、コンポーネントごとに、いろと、とうめいどを、オーバーライドできます。「すべてクリア」ボタンをおすと、すべてのオーバーライドを、やめます。",
"theme_help_v2_2": "バックグラウンドとテキストのコントラストをあらわすアイコンがあります。マウスをホバーすると、くわしいせつめいがでます。とうめいないろをつかっているときは、もっともわるいばあいのコントラストがしめされます。",
"tooltipRadius": "ツールチップとアラート",
"user_settings": "ユーザーせってい",
"values": {
"false": "いいえ",
"true": "はい"
},
"notifications": "つうち",
"enable_web_push_notifications": "ウェブプッシュつうちをゆるす",
"style": {
"switcher": {
"keep_color": "いろをのこす",
"keep_shadows": "かげをのこす",
"keep_opacity": "とうめいどをのこす",
"keep_roundness": "まるさをのこす",
"keep_fonts": "フォントをのこす",
"save_load_hint": "「のこす」オプションをONにすると、テーマをえらんだときとロードしたとき、いまのせっていをのこします。また、テーマをエクスポートするとき、これらのオプションをストアします。すべてのチェックボックスをOFFにすると、テーマをエクスポートしたとき、すべてのせっていをセーブします。",
"reset": "リセット",
"clear_all": "すべてクリア",
"clear_opacity": "とうめいどをクリア"
},
"common": {
"color": "いろ",
"opacity": "とうめいど",
"contrast": {
"hint": "コントラストは {ratio} です。{level}。({context})",
"level": {
"aa": "AAレベルガイドライン (ミニマル) をみたします",
"aaa": "AAAレベルガイドライン (レコメンデッド) をみたします。",
"bad": "ガイドラインをみたしません。"
},
"context": {
"18pt": "おおきい (18ポイントいじょう) テキスト",
"text": "テキスト"
}
}
},
"common_colors": {
"_tab_label": "きょうつう",
"main": "きょうつうのいろ",
"foreground_hint": "「くわしく」タブで、もっとこまかくせっていできます",
"rgbo": "アイコンとアクセントとバッジ"
},
"advanced_colors": {
"_tab_label": "くわしく",
"alert": "アラートのバックグラウンド",
"alert_error": "エラー",
"badge": "バッジのバックグラウンド",
"badge_notification": "つうち",
"panel_header": "パネルヘッダー",
"top_bar": "トップバー",
"borders": "さかいめ",
"buttons": "ボタン",
"inputs": "インプットフィールド",
"faint_text": "うすいテキスト"
},
"radii": {
"_tab_label": "まるさ"
},
"shadows": {
"_tab_label": "ひかりとかげ",
"component": "コンポーネント",
"override": "オーバーライド",
"shadow_id": "かげ #{value}",
"blur": "ぼかし",
"spread": "ひろがり",
"inset": "うちがわ",
"hint": "かげのせっていでは、いろのあたいとして --variable をつかうことができます。これはCSS3へんすうです。ただし、とうめいどのせっていは、きかなくなります。",
"filter_hint": {
"always_drop_shadow": "ブラウザーがサポートしていれば、つねに {0} がつかわれます。",
"drop_shadow_syntax": "{0} は、{1} パラメーターと {2} キーワードをサポートしていません。",
"avatar_inset": "うちがわのかげと、そとがわのかげを、いっしょにつかうと、とうめいなアバターが、へんなみためになります。",
"spread_zero": "ひろがりが 0 よりもおおきなかげは、0 とおなじです。",
"inset_classic": "うちがわのかげは {0} をつかいます。"
},
"components": {
"panel": "パネル",
"panelHeader": "パネルヘッダー",
"topBar": "トップバー",
"avatar": "ユーザーアバター (プロフィール)",
"avatarStatus": "ユーザーアバター (とうこう)",
"popup": "ポップアップとツールチップ",
"button": "ボタン",
"buttonHover": "ボタン (ホバー)",
"buttonPressed": "ボタン (おされているとき)",
"buttonPressedHover": "ボタン (ホバー、かつ、おされているとき)",
"input": "インプットフィールド"
}
},
"fonts": {
"_tab_label": "フォント",
"help": "「カスタム」をえらんだときは、システムにあるフォントのなまえを、ただしくにゅうりょくしてください。",
"components": {
"interface": "インターフェース",
"input": "インプットフィールド",
"post": "とうこう",
"postCode": "モノスペース (とうこうがリッチテキストであるとき)"
},
"family": "フォントめい",
"size": "おおきさ (px)",
"weight": "ふとさ",
"custom": "カスタム"
},
"preview": {
"header": "プレビュー",
"content": "ほんぶん",
"error": "エラーのれい",
"button": "ボタン",
"text": "これは{0}と{1}のれいです。",
"mono": "monospace",
"input": "はねだくうこうに、つきました。",
"faint_link": "とてもたすけになるマニュアル",
"fine_print": "わたしたちの{0}を、よまないでください!",
"header_faint": "エラーではありません",
"checkbox": "りようきやくを、よみました",
"link": "ハイパーリンク"
}
}
},
"timeline": {
"collapse": "たたむ",
"conversation": "スレッド",
"error_fetching": "よみこみがエラーになりました",
"load_older": "ふるいステータス",
"no_retweet_hint": "とうこうを「フォロワーのみ」または「ダイレクト」にすると、リピートできなくなります",
"repeated": "リピート",
"show_new": "よみこみ",
"up_to_date": "さいしん",
"no_more_statuses": "これでおわりです"
},
"user_card": {
"approve": "うけいれ",
"block": "ブロック",
"blocked": "ブロックしています!",
"deny": "おことわり",
"favorites": "おきにいり",
"follow": "フォロー",
"follow_sent": "リクエストを、おくりました!",
"follow_progress": "リクエストしています…",
"follow_again": "ふたたびリクエストをおくりますか?",
"follow_unfollow": "フォローをやめる",
"followees": "フォロー",
"followers": "フォロワー",
"following": "フォローしています!",
"follows_you": "フォローされました!",
"its_you": "これはあなたです!",
"media": "メディア",
"mute": "ミュート",
"muted": "ミュートしています!",
"per_day": "/日",
"remote_follow": "リモートフォロー",
"statuses": "ステータス"
},
"user_profile": {
"timeline_title": "ユーザータイムライン"
},
"who_to_follow": {
"more": "くわしく",
"who_to_follow": "おすすめユーザー"
},
"tool_tip": {
"media_upload": "メディアをアップロード",
"repeat": "リピート",
"reply": "リプライ",
"favorite": "おきにいり",
"user_settings": "ユーザーせってい"
},
"upload":{
"error": {
"base": "アップロードにしっぱいしました。",
"file_too_big": "ファイルがおおきすぎます [{filesize} {filesizeunit} / {allowedsize} {allowedsizeunit}]",
"default": "しばらくしてから、ためしてください"
},
"file_size_units": {
"B": "B",
"KiB": "KiB",
"MiB": "MiB",
"GiB": "GiB",
"TiB": "TiB"
}
}
}
diff --git a/src/i18n/oc.json b/src/i18n/oc.json
index ef32f83bd5..fd5ccc97ef 100644
--- a/src/i18n/oc.json
+++ b/src/i18n/oc.json
@@ -1,313 +1,312 @@
{
"chat": {
"title": "Messatjariá"
},
"features_panel": {
"chat": "Chat",
"gopher": "Gopher",
"media_proxy": "Servidor mandatari mèdia",
"scope_options": "Nivèls de confidencialitat",
"text_limit": "Limita de tèxte",
"title": "Foncionalitats",
"who_to_follow": "Qual seguir"
},
"finder": {
"error_fetching_user": "Error pendent la cèrca d’un utilizaire",
"find_user": "Cercar un utilizaire"
},
"general": {
"apply": "Aplicar",
"submit": "Mandar",
"more": "Mai",
"generic_error": "Una error s’es producha",
"optional": "opcional"
},
"image_cropper": {
"crop_picture": "Talhar l’imatge",
"save": "Salvar",
"cancel": "Anullar"
},
"login": {
"login": "Connexion",
"description": "Connexion via OAuth",
"logout": "Desconnexion",
"password": "Senhal",
"placeholder": "e.g. lain",
"register": "Se marcar",
"username": "Nom d’utilizaire",
"hint": "Connectatz-vos per participar a la discutida"
},
"media_modal": {
"previous": "Precedent",
"next": "Seguent"
},
"nav": {
"about": "A prepaus",
"back": "Tornar",
"chat": "Chat local",
"friend_requests": "Demandas de seguiment",
"mentions": "Notificacions",
"dms": "Messatges privats",
"public_tl": "Estatuts locals",
"timeline": "Flux d’actualitat",
"twkn": "Lo malhum conegut",
"user_search": "Cèrca d’utilizaires",
"who_to_follow": "Qual seguir",
"preferences": "Preferéncias"
},
"notifications": {
"broken_favorite": "Estatut desconegut, sèm a lo cercar...",
"favorited_you": "a aimat vòstre estatut",
"followed_you": "vos a seguit",
"load_older": "Cargar las notificaciones mai ancianas",
"notifications": "Notficacions",
"read": "Legit !",
"repeated_you": "a repetit vòstre estatut",
"no_more_notifications": "Pas mai de notificacions"
},
"post_status": {
"new_status": "Publicar d’estatuts novèls",
"account_not_locked_warning": "Vòstre compte es pas {0}. Qual que siá pòt vos seguir per veire vòstras publicacions destinadas pas qu'a vòstres seguidors.",
"account_not_locked_warning_link": "clavat",
"attachments_sensitive": "Marcar las pèças juntas coma sensiblas",
"content_type": {
"plain_text": "Tèxte brut"
},
"content_warning": "Avís de contengut (opcional)",
"default": "Escrivètz aquí vòstre estatut.",
"direct_warning": "Aquesta publicacion serà pas que visibla pels utilizaires mencionats.",
"posting": "Mandadís",
"scope": {
"direct": "Dirècte - Publicar pels utilizaires mencionats solament",
"private": "Seguidors solament - Publicar pels sols seguidors",
"public": "Public - Publicar pel flux d’actualitat public",
"unlisted": "Pas listat - Publicar pas pel flux public"
}
},
"registration": {
"bio": "Biografia",
"email": "Adreça de corrièl",
"fullname": "Nom complèt",
"password_confirm": "Confirmar lo senhal",
"registration": "Inscripcion",
"token": "Geton de convidat",
"captcha": "CAPTCHA",
"new_captcha": "Clicatz l’imatge per obténer una nòva captcha",
"username_placeholder": "e.g. lain",
"fullname_placeholder": "e.g. Lain Iwakura",
"bio_placeholder": "e.g.\nHi, Soi lo Lain\nSoi afocada d’animes e vivi al Japan. Benlèu que me coneissètz de the Wired.",
"validations": {
"username_required": "pòt pas èsser void",
"fullname_required": "pòt pas èsser void",
"email_required": "pòt pas èsser void",
"password_required": "pòt pas èsser void",
"password_confirmation_required": "pòt pas èsser void",
"password_confirmation_match": "deu èsser lo meteis senhal"
}
},
"settings": {
"app_name": "Nom de l’aplicacion",
"attachmentRadius": "Pèças juntas",
"attachments": "Pèças juntas",
"autoload": "Activar lo cargament automatic un còp arribat al cap de la pagina",
"avatar": "Avatar",
"avatarAltRadius": "Avatars (Notificacions)",
"avatarRadius": "Avatars",
"background": "Rèire plan",
"bio": "Biografia",
"blocks_tab": "Blocatges",
"btnRadius": "Botons",
"cBlue": "Blau (Respondre, seguir)",
"cGreen": "Verd (Repartajar)",
"cOrange": "Irange (Aimar)",
"cRed": "Roge (Anullar)",
"change_password": "Cambiar lo senhal",
"change_password_error": "Una error s’es producha en cambiant lo senhal.",
"changed_password": "Senhal corrèctament cambiat !",
"collapse_subject": "Replegar las publicacions amb de subjèctes",
"composing": "Escritura",
"confirm_new_password": "Confirmatz lo nòu senhal",
"current_avatar": "Vòstre avatar actual",
"current_password": "Senhal actual",
"current_profile_banner": "Bandièra actuala del perfil",
"data_import_export_tab": "Importar / Exportar las donadas",
"default_vis": "Nivèl de visibilitat per defaut",
"delete_account": "Suprimir lo compte",
"delete_account_description": "Suprimir vòstre compte e los messatges per sempre.",
"delete_account_error": "Una error s’es producha en suprimir lo compte. S’aquò ten d’arribar mercés de contactar vòstre administrador d’instància.",
"delete_account_instructions": "Picatz vòstre senhal dins lo camp tèxte çai-jos per confirmar la supression del compte.",
"avatar_size_instruction": "La talha minimum recomandada pels imatges d’avatar es 150x150 pixèls.",
"export_theme": "Enregistrar la preconfiguracion",
"filtering": "Filtratge",
"filtering_explanation": "Totes los estatuts amb aqueles mots seràn en silenci, un mot per linha",
"follow_export": "Exportar los abonaments",
"follow_export_button": "Exportar vòstres abonaments dins un fichièr csv",
"follow_export_processing": "Tractament, vos demandarem lèu de telecargar lo fichièr",
"follow_import": "Importar los abonaments",
"follow_import_error": "Error en important los seguidors",
"follows_imported": "Seguidors importats. Lo tractament pòt trigar una estona.",
"foreground": "Endavant",
"general": "General",
"hide_attachments_in_convo": "Rescondre las pèças juntas dins las conversacions",
"hide_attachments_in_tl": "Rescondre las pèças juntas",
"max_thumbnails": "Nombre maximum de vinhetas per publicacion",
"hide_isp": "Amagar lo panèl especial instància",
"preload_images": "Precargar los imatges",
"use_one_click_nsfw": "Dobrir las pèças juntas NSFW amb un clic",
"hide_post_stats": "Amagar los estatistics de publicacion (ex. lo ombre de favorits)",
"hide_user_stats": "Amagar las estatisticas de l’utilizaire (ex. lo nombre de seguidors)",
"hide_filtered_statuses": "Amagar los estatuts filtrats",
"import_followers_from_a_csv_file": "Importar los seguidors d’un fichièr csv",
"import_theme": "Cargar un tèma",
"inputRadius": "Camps tèxte",
"checkboxRadius": "Casas de marcar",
"instance_default": "(defaut : {value})",
"instance_default_simple": "(defaut)",
"interface": "Interfàcia",
"interfaceLanguage": "Lenga de l’interfàcia",
"invalid_theme_imported": "Lo fichièr seleccionat es pas un tèma Pleroma valid. Cap de cambiament es estat fach a vòstre tèma.",
"limited_availability": "Pas disponible per vòstre navigador",
"links": "Ligams",
"lock_account_description": "Limitar vòstre compte als seguidors acceptats solament",
"loop_video": "Bocla vidèo",
"loop_video_silent_only": "Legir en bocla solament las vidèos sens son (coma los « Gifs » de Mastodon)",
"mutes_tab": "Agamats",
"play_videos_in_modal": "Legir las vidèoas dirèctament dins la visualizaira mèdia",
"use_contain_fit": "Talhar pas las pèças juntas per las vinhetas",
"name": "Nom",
"name_bio": "Nom & Bio",
"new_password": "Nòu senhal",
"notification_visibility_follows": "Abonaments",
"notification_visibility_likes": "Aimar",
"notification_visibility_mentions": "Mencions",
"notification_visibility_repeats": "Repeticions",
"notification_visibility": "Tipes de notificacion de mostrar",
"no_rich_text_description": "Netejar lo format tèxte de totas las publicacions",
"no_blocks": "Cap de blocatge",
"no_mutes": "Cap d’amagat",
"hide_follows_description": "Mostrar pas qual seguissi",
"hide_followers_description": "Mostrar pas qual me seguisson",
"show_admin_badge": "Mostrar lo badge Admin badge al perfil meu",
"show_moderator_badge": "Mostrar lo badge Moderator al perfil meu",
"nsfw_clickthrough": "Activar lo clic per mostrar los imatges marcats coma pels adults o sensibles",
"oauth_tokens": "Listats OAuth",
"token": "Geton",
"refresh_token": "Actualizar lo geton",
"valid_until": "Valid fins a",
"revoke_token": "Revocar",
"panelRadius": "Panèls",
"pause_on_unfocused": "Pausar la difusion quand l’onglet es pas seleccionat",
"presets": "Pre-enregistrats",
"profile_background": "Imatge de fons",
"profile_banner": "Bandièra del perfil",
"profile_tab": "Perfil",
"radii_help": "Configurar los caires arredondits de l’interfàcia (en pixèls)",
"replies_in_timeline": "Responsas del flux",
"reply_link_preview": "Activar l’apercebut en passar la mirga",
"reply_visibility_all": "Mostrar totas las responsas",
"reply_visibility_following": "Mostrar pas que las responsas que me son destinada a ieu o un utilizaire que seguissi",
"reply_visibility_self": "Mostrar pas que las responsas que me son destinadas",
"saving_err": "Error en enregistrant los paramètres",
"saving_ok": "Paramètres enregistrats",
"scope_copy": "Copiar lo nivèl de confidencialitat per las responsas (Totjorn aissí pels Messatges Dirèctes)",
"security_tab": "Seguretat",
"set_new_avatar": "Definir un nòu avatar",
"set_new_profile_background": "Definir un nòu fons de perfil",
"set_new_profile_banner": "Definir una nòva bandièra de perfil",
"settings": "Paramètres",
"subject_input_always_show": "Totjorn mostrar lo camp de subjècte",
"subject_line_behavior": "Copiar lo subjècte per las responsas",
"subject_line_email": "Coma los corrièls : \"re: subjècte\"",
"subject_line_mastodon": "Coma mastodon : copiar tal coma es",
"subject_line_noop": "Copiar pas",
"post_status_content_type": "Publicar lo tipe de contengut dels estatuts",
- "status_content_type_plain": "Tèxte brut",
"stop_gifs": "Lançar los GIFs al subrevòl",
"streaming": "Activar lo cargament automatic dels novèls estatus en anar amont",
"text": "Tèxt",
"theme": "Tèma",
"theme_help_v2_1": "You can also override certain component's colors and opacity by toggling the checkbox, use \"Clear all\" button to clear all overrides.",
"theme_help_v2_2": "Icons underneath some entries are background/text contrast indicators, hover over for detailed info. Please keep in mind that when using transparency contrast indicators show the worst possible case.",
"theme_help": "Emplegatz los còdis de color hex (#rrggbb) per personalizar vòstre tèma de color.",
"tooltipRadius": "Astúcias/alèrtas",
"upload_a_photo": "Enviar una fotografia",
"user_settings": "Paramètres utilizaire",
"values": {
"false": "non",
"true": "òc"
}
},
"timeline": {
"collapse": "Tampar",
"conversation": "Conversacion",
"error_fetching": "Error en cercant de mesas a jorn",
"load_older": "Ne veire mai",
"repeated": "repetit",
"show_new": "Ne veire mai",
"up_to_date": "A jorn",
"no_retweet_hint": "La publicacion marcada coma pels seguidors solament o dirècte pòt pas èsser repetida"
},
"status": {
"reply_to": "Respondre à",
"replies_list": "Responsas :"
},
"user_card": {
"approve": "Validar",
"block": "Blocar",
"blocked": "Blocat !",
"deny": "Refusar",
"favorites": "Favorits",
"follow": "Seguir",
"follow_sent": "Demanda enviada !",
"follow_progress": "Demanda…",
"follow_again": "Tornar enviar la demanda ?",
"follow_unfollow": "Quitar de seguir",
"followees": "Abonaments",
"followers": "Seguidors",
"following": "Seguit !",
"follows_you": "Vos sèc !",
"its_you": "Sètz vos !",
"media": "Mèdia",
"mute": "Amagar",
"muted": "Amagat",
"per_day": "per jorn",
"remote_follow": "Seguir a distància",
"statuses": "Estatuts",
"unblock": "Desblocar",
"unblock_progress": "Desblocatge...",
"block_progress": "Blocatge...",
"unmute": "Tornar mostrar",
"unmute_progress": "Afichatge...",
"mute_progress": "A amagar..."
},
"user_profile": {
"timeline_title": "Flux utilizaire",
"profile_does_not_exist": "Aqueste perfil existís pas.",
"profile_loading_error": "Una error s’es producha en cargant aqueste perfil."
},
"who_to_follow": {
"more": "Mai",
"who_to_follow": "Qual seguir"
},
"tool_tip": {
"media_upload": "Enviar un mèdia",
"repeat": "Repetir",
"reply": "Respondre",
"favorite": "aimar",
"user_settings": "Paramètres utilizaire"
},
"upload":{
"error": {
"base": "Mandadís fracassat.",
"file_too_big": "Fichièr tròp grand [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]",
"default": "Tornatz ensajar mai tard"
},
"file_size_units": {
"B": "o",
"KiB": "Kio",
"MiB": "Mio",
"GiB": "Gio",
"TiB": "Tio"
}
}
}
\ No newline at end of file
diff --git a/src/i18n/pt.json b/src/i18n/pt.json
index 39ff6c63a7..cbc2c9a353 100644
--- a/src/i18n/pt.json
+++ b/src/i18n/pt.json
@@ -1,426 +1,425 @@
{
"chat": {
"title": "Chat"
},
"features_panel": {
"chat": "Chat",
"gopher": "Gopher",
"media_proxy": "Proxy de mídia",
"scope_options": "Opções de privacidade",
"text_limit": "Limite de caracteres",
"title": "Funções",
"who_to_follow": "Quem seguir"
},
"finder": {
"error_fetching_user": "Erro ao procurar usuário",
"find_user": "Buscar usuário"
},
"general": {
"apply": "Aplicar",
"submit": "Enviar",
"more": "Mais",
"generic_error": "Houve um erro",
"optional": "opcional"
},
"image_cropper": {
"crop_picture": "Cortar imagem",
"save": "Salvar",
"cancel": "Cancelar"
},
"login": {
"login": "Entrar",
"description": "Entrar com OAuth",
"logout": "Sair",
"password": "Senha",
"placeholder": "p.e. lain",
"register": "Registrar",
"username": "Usuário",
"hint": "Entre para participar da discussão"
},
"media_modal": {
"previous": "Anterior",
"next": "Próximo"
},
"nav": {
"about": "Sobre",
"back": "Voltar",
"chat": "Chat local",
"friend_requests": "Solicitações de seguidores",
"mentions": "Menções",
"dms": "Mensagens diretas",
"public_tl": "Linha do tempo pública",
"timeline": "Linha do tempo",
"twkn": "Toda a rede conhecida",
"user_search": "Busca de usuário",
"who_to_follow": "Quem seguir",
"preferences": "Preferências"
},
"notifications": {
"broken_favorite": "Status desconhecido, buscando...",
"favorited_you": "favoritou sua postagem",
"followed_you": "seguiu você",
"load_older": "Carregar notificações antigas",
"notifications": "Notificações",
"read": "Lido!",
"repeated_you": "repetiu sua postagem",
"no_more_notifications": "Mais nenhuma notificação"
},
"post_status": {
"new_status": "Postar novo status",
"account_not_locked_warning": "Sua conta não está {0}. Qualquer pessoa pode te seguir para ver seus posts restritos.",
"account_not_locked_warning_link": "fechada",
"attachments_sensitive": "Marcar anexos como sensíveis",
"content_type": {
"plain_text": "Texto puro"
},
"content_warning": "Assunto (opcional)",
"default": "Acabei de chegar no Rio!",
"direct_warning": "Este post será visível apenas para os usuários mencionados.",
"posting": "Publicando",
"scope": {
"direct": "Direto - Enviar somente aos usuários mencionados",
"private": "Apenas para seguidores - Enviar apenas para seguidores",
"public": "Público - Enviar a linhas do tempo públicas",
"unlisted": "Não listado - Não enviar a linhas do tempo públicas"
}
},
"registration": {
"bio": "Biografia",
"email": "Correio eletrônico",
"fullname": "Nome para exibição",
"password_confirm": "Confirmação de senha",
"registration": "Registro",
"token": "Código do convite",
"captcha": "CAPTCHA",
"new_captcha": "Clique na imagem para carregar um novo captcha",
"username_placeholder": "p. ex. lain",
"fullname_placeholder": "p. ex. Lain Iwakura",
"bio_placeholder": "e.g.\nOi, sou Lain\nSou uma garota que vive no subúrbio do Japão. Você deve me conhecer da Rede.",
"validations": {
"username_required": "não pode ser deixado em branco",
"fullname_required": "não pode ser deixado em branco",
"email_required": "não pode ser deixado em branco",
"password_required": "não pode ser deixado em branco",
"password_confirmation_required": "não pode ser deixado em branco",
"password_confirmation_match": "deve ser idêntica à senha"
}
},
"settings": {
"app_name": "Nome do aplicativo",
"attachmentRadius": "Anexos",
"attachments": "Anexos",
"autoload": "Habilitar carregamento automático quando a rolagem chegar ao fim.",
"avatar": "Avatar",
"avatarAltRadius": "Avatares (Notificações)",
"avatarRadius": "Avatares",
"background": "Pano de Fundo",
"bio": "Biografia",
"blocks_tab": "Blocos",
"btnRadius": "Botões",
"cBlue": "Azul (Responder, seguir)",
"cGreen": "Verde (Repetir)",
"cOrange": "Laranja (Favoritar)",
"cRed": "Vermelho (Cancelar)",
"change_password": "Mudar senha",
"change_password_error": "Houve um erro ao modificar sua senha.",
"changed_password": "Senha modificada com sucesso!",
"collapse_subject": "Esconder posts com assunto",
"composing": "Escrevendo",
"confirm_new_password": "Confirmar nova senha",
"current_avatar": "Seu avatar atual",
"current_password": "Sua senha atual",
"current_profile_banner": "Sua capa de perfil atual",
"data_import_export_tab": "Importação/exportação de dados",
"default_vis": "Opção de privacidade padrão",
"delete_account": "Deletar conta",
"delete_account_description": "Deletar sua conta e mensagens permanentemente.",
"delete_account_error": "Houve um problema ao deletar sua conta. Se ele persistir, por favor entre em contato com o/a administrador/a da instância.",
"delete_account_instructions": "Digite sua senha no campo abaixo para confirmar a exclusão da conta.",
"avatar_size_instruction": "O tamanho mínimo recomendado para imagens de avatar é 150x150 pixels.",
"export_theme": "Salvar predefinições",
"filtering": "Filtragem",
"filtering_explanation": "Todas as postagens contendo estas palavras serão silenciadas, uma por linha.",
"follow_export": "Exportar quem você segue",
"follow_export_button": "Exportar quem você segue para um arquivo CSV",
"follow_export_processing": "Processando. Em breve você receberá a solicitação de download do arquivo",
"follow_import": "Importar quem você segue",
"follow_import_error": "Erro ao importar seguidores",
"follows_imported": "Seguidores importados! O processamento pode demorar um pouco.",
"foreground": "Primeiro Plano",
"general": "Geral",
"hide_attachments_in_convo": "Ocultar anexos em conversas",
"hide_attachments_in_tl": "Ocultar anexos na linha do tempo.",
"max_thumbnails": "Número máximo de miniaturas por post",
"hide_isp": "Esconder painel específico da instância",
"preload_images": "Pré-carregar imagens",
"use_one_click_nsfw": "Abrir anexos sensíveis com um clique",
"hide_post_stats": "Esconder estatísticas de posts (p. ex. número de favoritos)",
"hide_user_stats": "Esconder estatísticas do usuário (p. ex. número de seguidores)",
"hide_filtered_statuses": "Esconder posts filtrados",
"import_followers_from_a_csv_file": "Importe seguidores a partir de um arquivo CSV",
"import_theme": "Carregar pré-definição",
"inputRadius": "Campos de entrada",
"checkboxRadius": "Checkboxes",
"instance_default": "(padrão: {value})",
"instance_default_simple": "(padrão)",
"interface": "Interface",
"interfaceLanguage": "Idioma da interface",
"invalid_theme_imported": "O arquivo selecionado não é um tema compatível com o Pleroma. Nenhuma mudança no tema foi feita.",
"limited_availability": "Indisponível para seu navegador",
"links": "Links",
"lock_account_description": "Restringir sua conta a seguidores aprovados",
"loop_video": "Repetir vídeos",
"loop_video_silent_only": "Repetir apenas vídeos sem som (como os \"gifs\" do Mastodon)",
"mutes_tab": "Silenciados",
"play_videos_in_modal": "Tocar vídeos diretamente no visualizador de mídia",
"use_contain_fit": "Não cortar o anexo na miniatura",
"name": "Nome",
"name_bio": "Nome & Biografia",
"new_password": "Nova senha",
"notification_visibility": "Tipos de notificação para mostrar",
"notification_visibility_follows": "Seguidos",
"notification_visibility_likes": "Favoritos",
"notification_visibility_mentions": "Menções",
"notification_visibility_repeats": "Repetições",
"no_rich_text_description": "Remover formatação de todos os posts",
"no_blocks": "Sem bloqueios",
"no_mutes": "Sem silenciados",
"hide_follows_description": "Não mostrar quem estou seguindo",
"hide_followers_description": "Não mostrar quem me segue",
"show_admin_badge": "Mostrar distintivo de Administrador em meu perfil",
"show_moderator_badge": "Mostrar título de Moderador em meu perfil",
"nsfw_clickthrough": "Habilitar clique para ocultar anexos sensíveis",
"oauth_tokens": "Token OAuth",
"token": "Token",
"refresh_token": "Atualizar Token",
"valid_until": "Válido até",
"revoke_token": "Revogar",
"panelRadius": "Paineis",
"pause_on_unfocused": "Parar transmissão quando a aba não estiver em primeiro plano",
"presets": "Predefinições",
"profile_background": "Pano de fundo de perfil",
"profile_banner": "Capa de perfil",
"profile_tab": "Perfil",
"radii_help": "Arredondar arestas da interface (em píxeis)",
"replies_in_timeline": "Respostas na linha do tempo",
"reply_link_preview": "Habilitar a pré-visualização de link de respostas ao passar o mouse.",
"reply_visibility_all": "Mostrar todas as respostas",
"reply_visibility_following": "Só mostrar respostas direcionadas a mim ou a usuários que sigo",
"reply_visibility_self": "Só mostrar respostas direcionadas a mim",
"saving_err": "Erro ao salvar configurações",
"saving_ok": "Configurações salvas",
"security_tab": "Segurança",
"scope_copy": "Copiar opções de privacidade ao responder (Mensagens diretas sempre copiam)",
"set_new_avatar": "Alterar avatar",
"set_new_profile_background": "Alterar o plano de fundo de perfil",
"set_new_profile_banner": "Alterar capa de perfil",
"settings": "Configurações",
"subject_input_always_show": "Sempre mostrar campo de assunto",
"subject_line_behavior": "Copiar assunto ao responder",
"subject_line_email": "Como em email: \"re: assunto\"",
"subject_line_mastodon": "Como o Mastodon: copiar como está",
"subject_line_noop": "Não copiar",
"post_status_content_type": "Postar tipo de conteúdo do status",
- "status_content_type_plain": "Texto puro",
"stop_gifs": "Reproduzir GIFs ao passar o cursor em cima",
"streaming": "Habilitar o fluxo automático de postagens quando ao topo da página",
"text": "Texto",
"theme": "Tema",
"theme_help": "Use cores em código hexadecimal (#rrggbb) para personalizar seu esquema de cores.",
"theme_help_v2_1": "Você também pode sobrescrever as cores e opacidade de alguns componentes ao modificar o checkbox, use \"Limpar todos\" para limpar todas as modificações.",
"theme_help_v2_2": "Alguns ícones sob registros são indicadores de fundo/contraste de textos, passe por cima para informações detalhadas. Tenha ciência de que os indicadores de contraste não funcionam muito bem com transparência.",
"tooltipRadius": "Dicas/alertas",
"upload_a_photo": "Enviar uma foto",
"user_settings": "Configurações de Usuário",
"values": {
"false": "não",
"true": "sim"
},
"notifications": "Notifications",
"enable_web_push_notifications": "Habilitar notificações web push",
"style": {
"switcher": {
"keep_color": "Manter cores",
"keep_shadows": "Manter sombras",
"keep_opacity": "Manter opacidade",
"keep_roundness": "Manter arredondado",
"keep_fonts": "Manter fontes",
"save_load_hint": "Manter as opções preserva as opções atuais ao selecionar ou carregar temas; também salva as opções ao exportar um tempo. Quanto todos os campos estiverem desmarcados, tudo será salvo ao exportar o tema.",
"reset": "Voltar ao padrão",
"clear_all": "Limpar tudo",
"clear_opacity": "Limpar opacidade"
},
"common": {
"color": "Cor",
"opacity": "Opacidade",
"contrast": {
"hint": "A taxa de contraste é {ratio}, {level} {context}",
"level": {
"aa": "padrão Nível AA (mínimo)",
"aaa": "padrão Nível AAA (recomendado)",
"bad": "nenhum padrão de acessibilidade"
},
"context": {
"18pt": "para textos longos (18pt+)",
"text": "para texto"
}
}
},
"common_colors": {
"_tab_label": "Comum",
"main": "Cores Comuns",
"foreground_hint": "Configurações mais detalhadas na aba\"Avançado\"",
"rgbo": "Ícones, acentuação, distintivos"
},
"advanced_colors": {
"_tab_label": "Avançado",
"alert": "Fundo de alerta",
"alert_error": "Erro",
"badge": "Fundo do distintivo",
"badge_notification": "Notificação",
"panel_header": "Topo do painel",
"top_bar": "Barra do topo",
"borders": "Bordas",
"buttons": "Botões",
"inputs": "Caixas de entrada",
"faint_text": "Texto esmaecido"
},
"radii": {
"_tab_label": "Arredondado"
},
"shadows": {
"_tab_label": "Luz e sombra",
"component": "Componente",
"override": "Sobrescrever",
"shadow_id": "Sombra #{value}",
"blur": "Borrado",
"spread": "Difusão",
"inset": "Inserção",
"hint": "Para as sombras você também pode usar --variável como valor de cor para utilizar variáveis do CSS3. Tenha em mente que configurar a opacidade não será possível neste caso.",
"filter_hint": {
"always_drop_shadow": "Atenção, esta sombra sempre utiliza {0} quando compatível com o navegador.",
"drop_shadow_syntax": "{0} não é compatível com o parâmetro {1} e a palavra-chave {2}.",
"avatar_inset": "Tenha em mente que combinar as sombras de inserção e a não-inserção em avatares pode causar resultados inesperados em avatares transparentes.",
"spread_zero": "Sombras com uma difusão > 0 aparecerão como se fossem definidas como 0.",
"inset_classic": "Sombras de inserção utilizarão {0}"
},
"components": {
"panel": "Painel",
"panelHeader": "Topo do painel",
"topBar": "Barra do topo",
"avatar": "Avatar do usuário (na visualização do perfil)",
"avatarStatus": "Avatar do usuário (na exibição de posts)",
"popup": "Dicas e notificações",
"button": "Botão",
"buttonHover": "Botão (em cima)",
"buttonPressed": "Botão (pressionado)",
"buttonPressedHover": "Botão (pressionado+em cima)",
"input": "Campo de entrada"
}
},
"fonts": {
"_tab_label": "Fontes",
"help": "Selecionar fonte dos elementos da interface. Para fonte \"personalizada\" você deve entrar exatamente o nome da fonte no sistema.",
"components": {
"interface": "Interface",
"input": "Campo de entrada",
"post": "Postar texto",
"postCode": "Texto monoespaçado em post (formatação rica)"
},
"family": "Nome da fonte",
"size": "Tamanho (em px)",
"weight": "Peso",
"custom": "Personalizada"
},
"preview": {
"header": "Pré-visualizar",
"content": "Conteúdo",
"error": "Erro de exemplo",
"button": "Botão",
"text": "Vários {0} e {1}",
"mono": "conteúdo",
"input": "Acabei de chegar no Rio!",
"faint_link": "manual útil",
"fine_print": "Leia nosso {0} para não aprender nada!",
"header_faint": "Está ok!",
"checkbox": "Li os termos e condições",
"link": "um belo link"
}
}
},
"timeline": {
"collapse": "Esconder",
"conversation": "Conversa",
"error_fetching": "Erro ao buscar atualizações",
"load_older": "Carregar postagens antigas",
"no_retweet_hint": "Posts apenas para seguidores ou diretos não podem ser repetidos",
"repeated": "Repetido",
"show_new": "Mostrar novas",
"up_to_date": "Atualizado",
"no_more_statuses": "Sem mais posts",
"no_statuses": "Sem posts"
},
"status": {
"reply_to": "Responder a",
"replies_list": "Respostas:"
},
"user_card": {
"approve": "Aprovar",
"block": "Bloquear",
"blocked": "Bloqueado!",
"deny": "Negar",
"favorites": "Favoritos",
"follow": "Seguir",
"follow_sent": "Pedido enviado!",
"follow_progress": "Enviando…",
"follow_again": "Enviar solicitação novamente?",
"follow_unfollow": "Deixar de seguir",
"followees": "Seguindo",
"followers": "Seguidores",
"following": "Seguindo!",
"follows_you": "Segue você!",
"its_you": "É você!",
"media": "Mídia",
"mute": "Silenciar",
"muted": "Silenciado",
"per_day": "por dia",
"remote_follow": "Seguidor Remoto",
"statuses": "Postagens",
"unblock": "Desbloquear",
"unblock_progress": "Desbloqueando...",
"block_progress": "Bloqueando...",
"unmute": "Retirar silêncio",
"unmute_progress": "Retirando silêncio...",
"mute_progress": "Silenciando..."
},
"user_profile": {
"timeline_title": "Linha do tempo do usuário",
"profile_does_not_exist": "Desculpe, este perfil não existe.",
"profile_loading_error": "Desculpe, houve um erro ao carregar este perfil."
},
"who_to_follow": {
"more": "Mais",
"who_to_follow": "Quem seguir"
},
"tool_tip": {
"media_upload": "Envio de mídia",
"repeat": "Repetir",
"reply": "Responder",
"favorite": "Favoritar",
"user_settings": "Configurações do usuário"
},
"upload":{
"error": {
"base": "Falha no envio.",
"file_too_big": "Arquivo grande demais [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]",
"default": "Tente novamente mais tarde"
},
"file_size_units": {
"B": "B",
"KiB": "KiB",
"MiB": "MiB",
"GiB": "GiB",
"TiB": "TiB"
}
}
}
diff --git a/src/modules/instance.js b/src/modules/instance.js
index c31d02b97f..24c52f9c11 100644
--- a/src/modules/instance.js
+++ b/src/modules/instance.js
@@ -1,78 +1,79 @@
import { set } from 'vue'
import { setPreset } from '../services/style_setter/style_setter.js'
const defaultState = {
// Stuff from static/config.json and apiConfig
name: 'Pleroma FE',
registrationOpen: true,
textlimit: 5000,
server: 'http://localhost:4040/',
theme: 'pleroma-dark',
background: '/static/aurora_borealis.jpg',
logo: '/static/logo.png',
logoMask: true,
logoMargin: '.2em',
redirectRootNoLogin: '/main/all',
redirectRootLogin: '/main/friends',
showInstanceSpecificPanel: false,
scopeOptionsEnabled: true,
formattingOptionsEnabled: false,
alwaysShowSubjectInput: true,
collapseMessageWithSubject: false,
hidePostStats: false,
hideUserStats: false,
hideFilteredStatuses: false,
disableChat: false,
scopeCopy: true,
subjectLineBehavior: 'email',
postContentType: 'text/plain',
loginMethod: 'password',
nsfwCensorImage: undefined,
vapidPublicKey: undefined,
noAttachmentLinks: false,
showFeaturesPanel: true,
// Nasty stuff
pleromaBackend: true,
emoji: [],
customEmoji: [],
restrictedNicknames: [],
+ postFormats: [],
// Feature-set, apparently, not everything here is reported...
mediaProxyAvailable: false,
chatAvailable: false,
gopherAvailable: false,
suggestionsEnabled: false,
suggestionsWeb: '',
// Html stuff
instanceSpecificPanelContent: '',
tos: ''
}
const instance = {
state: defaultState,
mutations: {
setInstanceOption (state, { name, value }) {
if (typeof value !== 'undefined') {
set(state, name, value)
}
}
},
actions: {
setInstanceOption ({ commit, dispatch }, { name, value }) {
commit('setInstanceOption', {name, value})
switch (name) {
case 'name':
dispatch('setPageTitle')
break
}
},
setTheme ({ commit }, themeName) {
commit('setInstanceOption', { name: 'theme', value: themeName })
return setPreset(themeName, commit)
}
}
}
export default instance
diff --git a/src/modules/statuses.js b/src/modules/statuses.js
index 96a3549dcc..7571b62abd 100644
--- a/src/modules/statuses.js
+++ b/src/modules/statuses.js
@@ -1,454 +1,481 @@
-import { remove, slice, each, find, maxBy, minBy, merge, last, isArray } from 'lodash'
+import { remove, slice, each, find, maxBy, minBy, merge, first, last, isArray } from 'lodash'
import apiService from '../services/api/api.service.js'
// import parse from '../services/status_parser/status_parser.js'
const emptyTl = (userId = 0) => ({
statuses: [],
statusesObject: {},
faves: [],
visibleStatuses: [],
visibleStatusesObject: {},
newStatusCount: 0,
maxId: 0,
minId: 0,
minVisibleId: 0,
loading: false,
followers: [],
friends: [],
userId,
flushMarker: 0
})
-export const defaultState = {
+export const defaultState = () => ({
allStatuses: [],
allStatusesObject: {},
maxId: 0,
notifications: {
desktopNotificationSilence: true,
maxId: 0,
minId: Number.POSITIVE_INFINITY,
data: [],
idStore: {},
loading: false,
- error: false
+ error: false,
+ fetcherId: null
},
favorites: new Set(),
error: false,
timelines: {
mentions: emptyTl(),
public: emptyTl(),
user: emptyTl(),
favorites: emptyTl(),
media: emptyTl(),
publicAndExternal: emptyTl(),
friends: emptyTl(),
tag: emptyTl(),
dms: emptyTl()
}
-}
+})
export const prepareStatus = (status) => {
// Set deleted flag
status.deleted = false
// To make the array reactive
status.attachments = status.attachments || []
return status
}
const visibleNotificationTypes = (rootState) => {
return [
rootState.config.notificationVisibility.likes && 'like',
rootState.config.notificationVisibility.mentions && 'mention',
rootState.config.notificationVisibility.repeats && 'repeat',
rootState.config.notificationVisibility.follows && 'follow'
].filter(_ => _)
}
const mergeOrAdd = (arr, obj, item) => {
const oldItem = obj[item.id]
if (oldItem) {
// We already have this, so only merge the new info.
merge(oldItem, item)
// Reactivity fix.
oldItem.attachments.splice(oldItem.attachments.length)
return {item: oldItem, new: false}
} else {
// This is a new item, prepare it
prepareStatus(item)
arr.push(item)
obj[item.id] = item
return {item, new: true}
}
}
const sortById = (a, b) => {
const seqA = Number(a.id)
const seqB = Number(b.id)
const isSeqA = !Number.isNaN(seqA)
const isSeqB = !Number.isNaN(seqB)
if (isSeqA && isSeqB) {
return seqA > seqB ? -1 : 1
} else if (isSeqA && !isSeqB) {
return 1
} else if (!isSeqA && isSeqB) {
return -1
} else {
return a.id > b.id ? -1 : 1
}
}
const sortTimeline = (timeline) => {
timeline.visibleStatuses = timeline.visibleStatuses.sort(sortById)
timeline.statuses = timeline.statuses.sort(sortById)
timeline.minVisibleId = (last(timeline.visibleStatuses) || {}).id
return timeline
}
const addNewStatuses = (state, { statuses, showImmediately = false, timeline, user = {}, noIdUpdate = false, userId }) => {
// Sanity check
if (!isArray(statuses)) {
return false
}
const allStatuses = state.allStatuses
const allStatusesObject = state.allStatusesObject
const timelineObject = state.timelines[timeline]
const maxNew = statuses.length > 0 ? maxBy(statuses, 'id').id : 0
const minNew = statuses.length > 0 ? minBy(statuses, 'id').id : 0
const newer = timeline && maxNew > timelineObject.maxId && statuses.length > 0
const older = timeline && (minNew < timelineObject.minId || timelineObject.minId === 0) && statuses.length > 0
if (!noIdUpdate && newer) {
timelineObject.maxId = maxNew
}
if (!noIdUpdate && older) {
timelineObject.minId = minNew
}
// This makes sure that user timeline won't get data meant for other
// user. I.e. opening different user profiles makes request which could
// return data late after user already viewing different user profile
if ((timeline === 'user' || timeline === 'media') && timelineObject.userId !== userId) {
return
}
const addStatus = (data, showImmediately, addToTimeline = true) => {
const result = mergeOrAdd(allStatuses, allStatusesObject, data)
const status = result.item
if (result.new) {
// We are mentioned in a post
if (status.type === 'status' && find(status.attentions, { id: user.id })) {
const mentions = state.timelines.mentions
// Add the mention to the mentions timeline
if (timelineObject !== mentions) {
mergeOrAdd(mentions.statuses, mentions.statusesObject, status)
mentions.newStatusCount += 1
sortTimeline(mentions)
}
}
if (status.visibility === 'direct') {
const dms = state.timelines.dms
mergeOrAdd(dms.statuses, dms.statusesObject, status)
dms.newStatusCount += 1
sortTimeline(dms)
}
}
// Decide if we should treat the status as new for this timeline.
let resultForCurrentTimeline
// Some statuses should only be added to the global status repository.
if (timeline && addToTimeline) {
resultForCurrentTimeline = mergeOrAdd(timelineObject.statuses, timelineObject.statusesObject, status)
}
if (timeline && showImmediately) {
// Add it directly to the visibleStatuses, don't change
// newStatusCount
mergeOrAdd(timelineObject.visibleStatuses, timelineObject.visibleStatusesObject, status)
} else if (timeline && addToTimeline && resultForCurrentTimeline.new) {
// Just change newStatuscount
timelineObject.newStatusCount += 1
}
return status
}
const favoriteStatus = (favorite, counter) => {
const status = find(allStatuses, { id: favorite.in_reply_to_status_id })
if (status) {
// This is our favorite, so the relevant bit.
if (favorite.user.id === user.id) {
status.favorited = true
} else {
status.fave_num += 1
}
}
return status
}
const processors = {
'status': (status) => {
addStatus(status, showImmediately)
},
'retweet': (status) => {
// RetweetedStatuses are never shown immediately
const retweetedStatus = addStatus(status.retweeted_status, false, false)
let retweet
// If the retweeted status is already there, don't add the retweet
// to the timeline.
if (timeline && find(timelineObject.statuses, (s) => {
if (s.retweeted_status) {
return s.id === retweetedStatus.id || s.retweeted_status.id === retweetedStatus.id
} else {
return s.id === retweetedStatus.id
}
})) {
// Already have it visible (either as the original or another RT), don't add to timeline, don't show.
retweet = addStatus(status, false, false)
} else {
retweet = addStatus(status, showImmediately)
}
retweet.retweeted_status = retweetedStatus
},
'favorite': (favorite) => {
// Only update if this is a new favorite.
// Ignore our own favorites because we get info about likes as response to like request
if (!state.favorites.has(favorite.id)) {
state.favorites.add(favorite.id)
favoriteStatus(favorite)
}
},
'deletion': (deletion) => {
const uri = deletion.uri
// Remove possible notification
const status = find(allStatuses, {uri})
if (!status) {
return
}
remove(state.notifications.data, ({action: {id}}) => id === status.id)
remove(allStatuses, { uri })
if (timeline) {
remove(timelineObject.statuses, { uri })
remove(timelineObject.visibleStatuses, { uri })
}
},
'follow': (follow) => {
// NOOP, it is known status but we don't do anything about it for now
},
'default': (unknown) => {
console.log('unknown status type')
console.log(unknown)
}
}
each(statuses, (status) => {
const type = status.type
const processor = processors[type] || processors['default']
processor(status)
})
// Keep the visible statuses sorted
if (timeline) {
sortTimeline(timelineObject)
}
}
const addNewNotifications = (state, { dispatch, notifications, older, visibleNotificationTypes }) => {
const allStatuses = state.allStatuses
const allStatusesObject = state.allStatusesObject
each(notifications, (notification) => {
notification.action = mergeOrAdd(allStatuses, allStatusesObject, notification.action).item
notification.status = notification.status && mergeOrAdd(allStatuses, allStatusesObject, notification.status).item
// Only add a new notification if we don't have one for the same action
if (!state.notifications.idStore.hasOwnProperty(notification.id)) {
state.notifications.maxId = notification.id > state.notifications.maxId
? notification.id
: state.notifications.maxId
state.notifications.minId = notification.id < state.notifications.minId
? notification.id
: state.notifications.minId
state.notifications.data.push(notification)
state.notifications.idStore[notification.id] = notification
if ('Notification' in window && window.Notification.permission === 'granted') {
const notifObj = {}
const action = notification.action
const title = action.user.name
notifObj.icon = action.user.profile_image_url
notifObj.body = action.text // there's a problem that it doesn't put a space before links tho
// Shows first attached non-nsfw image, if any. Should add configuration for this somehow...
if (action.attachments && action.attachments.length > 0 && !action.nsfw &&
action.attachments[0].mimetype.startsWith('image/')) {
notifObj.image = action.attachments[0].url
}
if (!notification.seen && !state.notifications.desktopNotificationSilence && visibleNotificationTypes.includes(notification.type)) {
let notification = new window.Notification(title, notifObj)
// Chrome is known for not closing notifications automatically
// according to MDN, anyway.
setTimeout(notification.close.bind(notification), 5000)
}
}
} else if (notification.seen) {
state.notifications.idStore[notification.id].seen = true
}
})
}
+const removeStatus = (state, { timeline, userId }) => {
+ const timelineObject = state.timelines[timeline]
+ if (userId) {
+ remove(timelineObject.statuses, { user: { id: userId } })
+ remove(timelineObject.visibleStatuses, { user: { id: userId } })
+ timelineObject.minVisibleId = timelineObject.visibleStatuses.length > 0 ? last(timelineObject.visibleStatuses).id : 0
+ timelineObject.maxId = timelineObject.statuses.length > 0 ? first(timelineObject.statuses).id : 0
+ }
+}
+
export const mutations = {
addNewStatuses,
addNewNotifications,
+ removeStatus,
showNewStatuses (state, { timeline }) {
const oldTimeline = (state.timelines[timeline])
oldTimeline.newStatusCount = 0
oldTimeline.visibleStatuses = slice(oldTimeline.statuses, 0, 50)
oldTimeline.minVisibleId = last(oldTimeline.visibleStatuses).id
oldTimeline.visibleStatusesObject = {}
each(oldTimeline.visibleStatuses, (status) => { oldTimeline.visibleStatusesObject[status.id] = status })
},
+ setNotificationFetcher (state, { fetcherId }) {
+ state.notifications.fetcherId = fetcherId
+ },
+ resetStatuses (state) {
+ const emptyState = defaultState()
+ Object.entries(emptyState).forEach(([key, value]) => {
+ state[key] = value
+ })
+ },
clearTimeline (state, { timeline }) {
state.timelines[timeline] = emptyTl(state.timelines[timeline].userId)
},
setFavorited (state, { status, value }) {
const newStatus = state.allStatusesObject[status.id]
newStatus.favorited = value
},
setFavoritedConfirm (state, { status }) {
const newStatus = state.allStatusesObject[status.id]
newStatus.favorited = status.favorited
newStatus.fave_num = status.fave_num
},
setRetweeted (state, { status, value }) {
const newStatus = state.allStatusesObject[status.id]
newStatus.repeated = value
},
setDeleted (state, { status }) {
const newStatus = state.allStatusesObject[status.id]
newStatus.deleted = true
},
setLoading (state, { timeline, value }) {
state.timelines[timeline].loading = value
},
setNsfw (state, { id, nsfw }) {
const newStatus = state.allStatusesObject[id]
newStatus.nsfw = nsfw
},
setError (state, { value }) {
state.error = value
},
setNotificationsLoading (state, { value }) {
state.notifications.loading = value
},
setNotificationsError (state, { value }) {
state.notifications.error = value
},
setNotificationsSilence (state, { value }) {
state.notifications.desktopNotificationSilence = value
},
markNotificationsAsSeen (state) {
each(state.notifications.data, (notification) => {
notification.seen = true
})
},
queueFlush (state, { timeline, id }) {
state.timelines[timeline].flushMarker = id
}
}
const statuses = {
- state: defaultState,
+ state: defaultState(),
actions: {
addNewStatuses ({ rootState, commit }, { statuses, showImmediately = false, timeline = false, noIdUpdate = false, userId }) {
commit('addNewStatuses', { statuses, showImmediately, timeline, noIdUpdate, user: rootState.users.currentUser, userId })
},
addNewNotifications ({ rootState, commit, dispatch }, { notifications, older }) {
commit('addNewNotifications', { visibleNotificationTypes: visibleNotificationTypes(rootState), dispatch, notifications, older })
},
setError ({ rootState, commit }, { value }) {
commit('setError', { value })
},
setNotificationsLoading ({ rootState, commit }, { value }) {
commit('setNotificationsLoading', { value })
},
setNotificationsError ({ rootState, commit }, { value }) {
commit('setNotificationsError', { value })
},
setNotificationsSilence ({ rootState, commit }, { value }) {
commit('setNotificationsSilence', { value })
},
+ stopFetchingNotifications ({ rootState, commit }) {
+ if (rootState.statuses.notifications.fetcherId) {
+ window.clearInterval(rootState.statuses.notifications.fetcherId)
+ }
+ commit('setNotificationFetcher', { fetcherId: null })
+ },
deleteStatus ({ rootState, commit }, status) {
commit('setDeleted', { status })
apiService.deleteStatus({ id: status.id, credentials: rootState.users.currentUser.credentials })
},
favorite ({ rootState, commit }, status) {
// Optimistic favoriting...
commit('setFavorited', { status, value: true })
apiService.favorite({ id: status.id, credentials: rootState.users.currentUser.credentials })
.then(response => {
if (response.ok) {
return response.json()
} else {
return {}
}
})
.then(status => {
commit('setFavoritedConfirm', { status })
})
},
unfavorite ({ rootState, commit }, status) {
// Optimistic favoriting...
commit('setFavorited', { status, value: false })
apiService.unfavorite({ id: status.id, credentials: rootState.users.currentUser.credentials })
.then(response => {
if (response.ok) {
return response.json()
} else {
return {}
}
})
.then(status => {
commit('setFavoritedConfirm', { status })
})
},
retweet ({ rootState, commit }, status) {
// Optimistic retweeting...
commit('setRetweeted', { status, value: true })
apiService.retweet({ id: status.id, credentials: rootState.users.currentUser.credentials })
},
unretweet ({ rootState, commit }, status) {
commit('setRetweeted', { status, value: false })
apiService.unretweet({ id: status.id, credentials: rootState.users.currentUser.credentials })
},
queueFlush ({ rootState, commit }, { timeline, id }) {
commit('queueFlush', { timeline, id })
},
markNotificationsAsSeen ({ rootState, commit }) {
commit('markNotificationsAsSeen')
apiService.markNotificationsAsSeen({
id: rootState.statuses.notifications.maxId,
credentials: rootState.users.currentUser.credentials
})
}
},
mutations
}
export default statuses
diff --git a/src/modules/users.js b/src/modules/users.js
index 093af497c3..4159964c79 100644
--- a/src/modules/users.js
+++ b/src/modules/users.js
@@ -1,356 +1,358 @@
import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
import { compact, map, each, merge, find } from 'lodash'
import { set } from 'vue'
import { registerPushNotifications, unregisterPushNotifications } from '../services/push/push.js'
import oauthApi from '../services/new_api/oauth'
import { humanizeErrors } from './errors'
// TODO: Unify with mergeOrAdd in statuses.js
export const mergeOrAdd = (arr, obj, item) => {
if (!item) { return false }
const oldItem = obj[item.id]
if (oldItem) {
// We already have this, so only merge the new info.
merge(oldItem, item)
return { item: oldItem, new: false }
} else {
// This is a new item, prepare it
arr.push(item)
obj[item.id] = item
if (item.screen_name && !item.screen_name.includes('@')) {
obj[item.screen_name] = item
}
return { item, new: true }
}
}
const getNotificationPermission = () => {
const Notification = window.Notification
if (!Notification) return Promise.resolve(null)
if (Notification.permission === 'default') return Notification.requestPermission()
return Promise.resolve(Notification.permission)
}
export const mutations = {
setMuted (state, { user: { id }, muted }) {
const user = state.usersObject[id]
set(user, 'muted', muted)
},
setCurrentUser (state, user) {
state.lastLoginName = user.screen_name
state.currentUser = merge(state.currentUser || {}, user)
},
clearCurrentUser (state) {
state.currentUser = false
state.lastLoginName = false
},
beginLogin (state) {
state.loggingIn = true
},
endLogin (state) {
state.loggingIn = false
},
// TODO Clean after ourselves?
addFriends (state, { id, friends, page }) {
const user = state.usersObject[id]
each(friends, friend => {
if (!find(user.friends, { id: friend.id })) {
user.friends.push(friend)
}
})
user.friendsPage = page + 1
},
addFollowers (state, { id, followers, page }) {
const user = state.usersObject[id]
each(followers, follower => {
if (!find(user.followers, { id: follower.id })) {
user.followers.push(follower)
}
})
user.followersPage = page + 1
},
// Because frontend doesn't have a reason to keep these stuff in memory
// outside of viewing someones user profile.
clearFriends (state, userId) {
const user = state.usersObject[userId]
if (!user) {
return
}
user.friends = []
user.friendsPage = 0
},
clearFollowers (state, userId) {
const user = state.usersObject[userId]
if (!user) {
return
}
user.followers = []
user.followersPage = 0
},
addNewUsers (state, users) {
each(users, (user) => mergeOrAdd(state.users, state.usersObject, user))
},
saveBlocks (state, blockIds) {
state.currentUser.blockIds = blockIds
},
saveMutes (state, muteIds) {
state.currentUser.muteIds = muteIds
},
setUserForStatus (state, status) {
status.user = state.usersObject[status.user.id]
},
setUserForNotification (state, notification) {
notification.action.user = state.usersObject[notification.action.user.id]
notification.from_profile = state.usersObject[notification.action.user.id]
},
setColor (state, { user: { id }, highlighted }) {
const user = state.usersObject[id]
set(user, 'highlight', highlighted)
},
signUpPending (state) {
state.signUpPending = true
state.signUpErrors = []
},
signUpSuccess (state) {
state.signUpPending = false
},
signUpFailure (state, errors) {
state.signUpPending = false
state.signUpErrors = errors
}
}
export const getters = {
userById: state => id =>
state.users.find(user => user.id === id),
userByName: state => name =>
state.users.find(user => user.screen_name &&
(user.screen_name.toLowerCase() === name.toLowerCase())
)
}
export const defaultState = {
loggingIn: false,
lastLoginName: false,
currentUser: false,
users: [],
usersObject: {},
signUpPending: false,
signUpErrors: []
}
const users = {
state: defaultState,
mutations,
getters,
actions: {
fetchUser (store, id) {
return store.rootState.api.backendInteractor.fetchUser({ id })
.then((user) => store.commit('addNewUsers', [user]))
},
fetchBlocks (store) {
return store.rootState.api.backendInteractor.fetchBlocks()
.then((blocks) => {
store.commit('saveBlocks', map(blocks, 'id'))
store.commit('addNewUsers', blocks)
return blocks
})
},
blockUser (store, id) {
return store.rootState.api.backendInteractor.blockUser(id)
.then((user) => store.commit('addNewUsers', [user]))
},
unblockUser (store, id) {
return store.rootState.api.backendInteractor.unblockUser(id)
.then((user) => store.commit('addNewUsers', [user]))
},
fetchMutes (store) {
return store.rootState.api.backendInteractor.fetchMutes()
.then((mutedUsers) => {
each(mutedUsers, (user) => { user.muted = true })
store.commit('addNewUsers', mutedUsers)
store.commit('saveMutes', map(mutedUsers, 'id'))
})
},
muteUser (store, id) {
return store.state.api.backendInteractor.setUserMute({ id, muted: true })
.then((user) => store.commit('addNewUsers', [user]))
},
unmuteUser (store, id) {
return store.state.api.backendInteractor.setUserMute({ id, muted: false })
.then((user) => store.commit('addNewUsers', [user]))
},
addFriends ({ rootState, commit }, fetchBy) {
return new Promise((resolve, reject) => {
const user = rootState.users.usersObject[fetchBy]
const page = user.friendsPage || 1
rootState.api.backendInteractor.fetchFriends({ id: user.id, page })
.then((friends) => {
commit('addFriends', { id: user.id, friends, page })
resolve(friends)
}).catch(() => {
reject()
})
})
},
addFollowers ({ rootState, commit }, fetchBy) {
const user = rootState.users.usersObject[fetchBy]
const page = user.followersPage || 1
return rootState.api.backendInteractor.fetchFollowers({ id: user.id, page })
.then((followers) => {
commit('addFollowers', { id: user.id, followers, page })
return followers
})
},
clearFriends ({ commit }, userId) {
commit('clearFriends', userId)
},
clearFollowers ({ commit }, userId) {
commit('clearFollowers', userId)
},
registerPushNotifications (store) {
const token = store.state.currentUser.credentials
const vapidPublicKey = store.rootState.instance.vapidPublicKey
const isEnabled = store.rootState.config.webPushNotifications
const notificationVisibility = store.rootState.config.notificationVisibility
registerPushNotifications(isEnabled, vapidPublicKey, token, notificationVisibility)
},
unregisterPushNotifications (store) {
const token = store.state.currentUser.credentials
unregisterPushNotifications(token)
},
addNewStatuses (store, { statuses }) {
const users = map(statuses, 'user')
const retweetedUsers = compact(map(statuses, 'retweeted_status.user'))
store.commit('addNewUsers', users)
store.commit('addNewUsers', retweetedUsers)
// Reconnect users to statuses
each(statuses, (status) => {
store.commit('setUserForStatus', status)
})
// Reconnect users to retweets
each(compact(map(statuses, 'retweeted_status')), (status) => {
store.commit('setUserForStatus', status)
})
},
addNewNotifications (store, { notifications }) {
const users = map(notifications, 'from_profile')
const notificationIds = notifications.map(_ => _.id)
store.commit('addNewUsers', users)
const notificationsObject = store.rootState.statuses.notifications.idStore
const relevantNotifications = Object.entries(notificationsObject)
.filter(([k, val]) => notificationIds.includes(k))
.map(([k, val]) => val)
// Reconnect users to notifications
each(relevantNotifications, (notification) => {
store.commit('setUserForNotification', notification)
})
},
async signUp (store, userInfo) {
store.commit('signUpPending')
let rootState = store.rootState
let response = await rootState.api.backendInteractor.register(userInfo)
if (response.ok) {
const data = {
oauth: rootState.oauth,
instance: rootState.instance.server
}
let app = await oauthApi.getOrCreateApp(data)
let result = await oauthApi.getTokenWithCredentials({
app,
instance: data.instance,
username: userInfo.username,
password: userInfo.password
})
store.commit('signUpSuccess')
store.commit('setToken', result.access_token)
store.dispatch('loginUser', result.access_token)
} else {
const data = await response.json()
let errors = JSON.parse(data.error)
// replace ap_id with username
if (errors.ap_id) {
errors.username = errors.ap_id
delete errors.ap_id
}
errors = humanizeErrors(errors)
store.commit('signUpFailure', errors)
throw Error(errors)
}
},
async getCaptcha (store) {
return await store.rootState.api.backendInteractor.getCaptcha()
},
logout (store) {
store.commit('clearCurrentUser')
store.commit('setToken', false)
store.dispatch('stopFetching', 'friends')
store.commit('setBackendInteractor', backendInteractorService())
+ store.dispatch('stopFetchingNotifications')
+ store.commit('resetStatuses')
},
loginUser (store, accessToken) {
return new Promise((resolve, reject) => {
const commit = store.commit
commit('beginLogin')
store.rootState.api.backendInteractor.verifyCredentials(accessToken)
.then((data) => {
if (!data.error) {
const user = data
// user.credentials = userCredentials
user.credentials = accessToken
user.blockIds = []
user.muteIds = []
commit('setCurrentUser', user)
commit('addNewUsers', [user])
getNotificationPermission()
.then(permission => commit('setNotificationPermission', permission))
// Set our new backend interactor
commit('setBackendInteractor', backendInteractorService(accessToken))
if (user.token) {
store.dispatch('setWsToken', user.token)
}
// Start getting fresh posts.
store.dispatch('startFetching', { timeline: 'friends' })
// Get user mutes
store.dispatch('fetchMutes')
// Fetch our friends
store.rootState.api.backendInteractor.fetchFriends({ id: user.id })
.then((friends) => commit('addNewUsers', friends))
} else {
const response = data.error
// Authentication failed
commit('endLogin')
if (response.status === 401) {
reject('Wrong username or password')
} else {
reject('An error occurred, please try again')
}
}
commit('endLogin')
resolve()
})
.catch((error) => {
console.log(error)
commit('endLogin')
reject('Failed to connect to server, try again')
})
})
}
}
}
export default users
diff --git a/test/unit/specs/boot/routes.spec.js b/test/unit/specs/boot/routes.spec.js
index 383ba90f2a..a415aeafc5 100644
--- a/test/unit/specs/boot/routes.spec.js
+++ b/test/unit/specs/boot/routes.spec.js
@@ -1,37 +1,37 @@
import routes from 'src/boot/routes'
import { createLocalVue } from '@vue/test-utils'
import VueRouter from 'vue-router'
const localVue = createLocalVue()
localVue.use(VueRouter)
describe('routes', () => {
const router = new VueRouter({
mode: 'abstract',
routes: routes({})
})
it('root path', () => {
router.push('/main/all')
const matchedComponents = router.getMatchedComponents()
expect(matchedComponents[0].components.hasOwnProperty('Timeline')).to.eql(true)
})
it('user\'s profile', () => {
router.push('/fake-user-name')
const matchedComponents = router.getMatchedComponents()
- expect(matchedComponents[0].components.hasOwnProperty('UserCardContent')).to.eql(true)
+ expect(matchedComponents[0].components.hasOwnProperty('UserCard')).to.eql(true)
})
it('user\'s profile at /users', () => {
router.push('/users/fake-user-name')
const matchedComponents = router.getMatchedComponents()
- expect(matchedComponents[0].components.hasOwnProperty('UserCardContent')).to.eql(true)
+ expect(matchedComponents[0].components.hasOwnProperty('UserCard')).to.eql(true)
})
})
diff --git a/test/unit/specs/modules/statuses.spec.js b/test/unit/specs/modules/statuses.spec.js
index 01d2ce061f..864b798dd1 100644
--- a/test/unit/specs/modules/statuses.spec.js
+++ b/test/unit/specs/modules/statuses.spec.js
@@ -1,307 +1,306 @@
-import { cloneDeep } from 'lodash'
import { defaultState, mutations, prepareStatus } from '../../../../src/modules/statuses.js'
// eslint-disable-next-line camelcase
const makeMockStatus = ({id, text, type = 'status'}) => {
return {
id,
user: {id: '0'},
name: 'status',
text: text || `Text number ${id}`,
fave_num: 0,
uri: '',
type,
attentions: []
}
}
describe('Statuses.prepareStatus', () => {
it('sets deleted flag to false', () => {
const aStatus = makeMockStatus({id: '1', text: 'Hello oniichan'})
expect(prepareStatus(aStatus).deleted).to.eq(false)
})
})
describe('The Statuses module', () => {
it('adds the status to allStatuses and to the given timeline', () => {
- const state = cloneDeep(defaultState)
+ const state = defaultState()
const status = makeMockStatus({id: '1'})
mutations.addNewStatuses(state, { statuses: [status], timeline: 'public' })
expect(state.allStatuses).to.eql([status])
expect(state.timelines.public.statuses).to.eql([status])
expect(state.timelines.public.visibleStatuses).to.eql([])
expect(state.timelines.public.newStatusCount).to.equal(1)
})
it('counts the status as new if it has not been seen on this timeline', () => {
- const state = cloneDeep(defaultState)
+ const state = defaultState()
const status = makeMockStatus({id: '1'})
mutations.addNewStatuses(state, { statuses: [status], timeline: 'public' })
mutations.addNewStatuses(state, { statuses: [status], timeline: 'friends' })
expect(state.allStatuses).to.eql([status])
expect(state.timelines.public.statuses).to.eql([status])
expect(state.timelines.public.visibleStatuses).to.eql([])
expect(state.timelines.public.newStatusCount).to.equal(1)
expect(state.allStatuses).to.eql([status])
expect(state.timelines.friends.statuses).to.eql([status])
expect(state.timelines.friends.visibleStatuses).to.eql([])
expect(state.timelines.friends.newStatusCount).to.equal(1)
})
it('add the statuses to allStatuses if no timeline is given', () => {
- const state = cloneDeep(defaultState)
+ const state = defaultState()
const status = makeMockStatus({id: '1'})
mutations.addNewStatuses(state, { statuses: [status] })
expect(state.allStatuses).to.eql([status])
expect(state.timelines.public.statuses).to.eql([])
expect(state.timelines.public.visibleStatuses).to.eql([])
expect(state.timelines.public.newStatusCount).to.equal(0)
})
it('adds the status to allStatuses and to the given timeline, directly visible', () => {
- const state = cloneDeep(defaultState)
+ const state = defaultState()
const status = makeMockStatus({id: '1'})
mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' })
expect(state.allStatuses).to.eql([status])
expect(state.timelines.public.statuses).to.eql([status])
expect(state.timelines.public.visibleStatuses).to.eql([status])
expect(state.timelines.public.newStatusCount).to.equal(0)
})
it('removes statuses by tag on deletion', () => {
- const state = cloneDeep(defaultState)
+ const state = defaultState()
const status = makeMockStatus({id: '1'})
const otherStatus = makeMockStatus({id: '3'})
status.uri = 'xxx'
const deletion = makeMockStatus({id: '2', type: 'deletion'})
deletion.text = 'Dolus deleted notice {{tag:gs.smuglo.li,2016-11-18:noticeId=1038007:objectType=note}}.'
deletion.uri = 'xxx'
mutations.addNewStatuses(state, { statuses: [status, otherStatus], showImmediately: true, timeline: 'public' })
mutations.addNewStatuses(state, { statuses: [deletion], showImmediately: true, timeline: 'public' })
expect(state.allStatuses).to.eql([otherStatus])
expect(state.timelines.public.statuses).to.eql([otherStatus])
expect(state.timelines.public.visibleStatuses).to.eql([otherStatus])
expect(state.timelines.public.maxId).to.eql('3')
})
it('does not update the maxId when the noIdUpdate flag is set', () => {
- const state = cloneDeep(defaultState)
+ const state = defaultState()
const status = makeMockStatus({id: '1'})
const secondStatus = makeMockStatus({id: '2'})
mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' })
expect(state.timelines.public.maxId).to.eql('1')
mutations.addNewStatuses(state, { statuses: [secondStatus], showImmediately: true, timeline: 'public', noIdUpdate: true })
expect(state.timelines.public.statuses).to.eql([secondStatus, status])
expect(state.timelines.public.visibleStatuses).to.eql([secondStatus, status])
expect(state.timelines.public.maxId).to.eql('1')
})
it('keeps a descending by id order in timeline.visibleStatuses and timeline.statuses', () => {
- const state = cloneDeep(defaultState)
+ const state = defaultState()
const nonVisibleStatus = makeMockStatus({id: '1'})
const status = makeMockStatus({id: '3'})
const statusTwo = makeMockStatus({id: '2'})
const statusThree = makeMockStatus({id: '4'})
mutations.addNewStatuses(state, { statuses: [nonVisibleStatus], showImmediately: false, timeline: 'public' })
mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' })
mutations.addNewStatuses(state, { statuses: [statusTwo], showImmediately: true, timeline: 'public' })
expect(state.timelines.public.minVisibleId).to.equal('2')
mutations.addNewStatuses(state, { statuses: [statusThree], showImmediately: true, timeline: 'public' })
expect(state.timelines.public.statuses).to.eql([statusThree, status, statusTwo, nonVisibleStatus])
expect(state.timelines.public.visibleStatuses).to.eql([statusThree, status, statusTwo])
})
it('splits retweets from their status and links them', () => {
- const state = cloneDeep(defaultState)
+ const state = defaultState()
const status = makeMockStatus({id: '1'})
const retweet = makeMockStatus({id: '2', type: 'retweet'})
const modStatus = makeMockStatus({id: '1', text: 'something else'})
retweet.retweeted_status = status
// It adds both statuses, but only the retweet to visible.
mutations.addNewStatuses(state, { statuses: [retweet], timeline: 'public', showImmediately: true })
expect(state.timelines.public.visibleStatuses).to.have.length(1)
expect(state.timelines.public.statuses).to.have.length(1)
expect(state.allStatuses).to.have.length(2)
expect(state.allStatuses[0].id).to.equal('1')
expect(state.allStatuses[1].id).to.equal('2')
// It refers to the modified status.
mutations.addNewStatuses(state, { statuses: [modStatus], timeline: 'public' })
expect(state.allStatuses).to.have.length(2)
expect(state.allStatuses[0].id).to.equal('1')
expect(state.allStatuses[0].text).to.equal(modStatus.text)
expect(state.allStatuses[1].id).to.equal('2')
expect(retweet.retweeted_status.text).to.eql(modStatus.text)
})
it('replaces existing statuses with the same id', () => {
- const state = cloneDeep(defaultState)
+ const state = defaultState()
const status = makeMockStatus({id: '1'})
const modStatus = makeMockStatus({id: '1', text: 'something else'})
// Add original status
mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' })
expect(state.timelines.public.visibleStatuses).to.have.length(1)
expect(state.allStatuses).to.have.length(1)
// Add new version of status
mutations.addNewStatuses(state, { statuses: [modStatus], showImmediately: true, timeline: 'public' })
expect(state.timelines.public.visibleStatuses).to.have.length(1)
expect(state.allStatuses).to.have.length(1)
expect(state.allStatuses[0].text).to.eql(modStatus.text)
})
it('replaces existing statuses with the same id, coming from a retweet', () => {
- const state = cloneDeep(defaultState)
+ const state = defaultState()
const status = makeMockStatus({id: '1'})
const modStatus = makeMockStatus({id: '1', text: 'something else'})
const retweet = makeMockStatus({id: '2', type: 'retweet'})
retweet.retweeted_status = modStatus
// Add original status
mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' })
expect(state.timelines.public.visibleStatuses).to.have.length(1)
expect(state.allStatuses).to.have.length(1)
// Add new version of status
mutations.addNewStatuses(state, { statuses: [retweet], showImmediately: false, timeline: 'public' })
expect(state.timelines.public.visibleStatuses).to.have.length(1)
// Don't add the retweet itself if the tweet is visible
expect(state.timelines.public.statuses).to.have.length(1)
expect(state.allStatuses).to.have.length(2)
expect(state.allStatuses[0].text).to.eql(modStatus.text)
})
it('handles favorite actions', () => {
- const state = cloneDeep(defaultState)
+ const state = defaultState()
const status = makeMockStatus({id: '1'})
const favorite = {
id: '2',
type: 'favorite',
in_reply_to_status_id: '1', // The API uses strings here...
uri: 'tag:shitposter.club,2016-08-21:fave:3895:note:773501:2016-08-21T16:52:15+00:00',
text: 'a favorited something by b',
user: { id: '99' }
}
mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' })
mutations.addNewStatuses(state, { statuses: [favorite], showImmediately: true, timeline: 'public' })
expect(state.timelines.public.visibleStatuses.length).to.eql(1)
expect(state.timelines.public.visibleStatuses[0].fave_num).to.eql(1)
expect(state.timelines.public.maxId).to.eq(favorite.id)
// Adding it again does nothing
mutations.addNewStatuses(state, { statuses: [favorite], showImmediately: true, timeline: 'public' })
expect(state.timelines.public.visibleStatuses.length).to.eql(1)
expect(state.timelines.public.visibleStatuses[0].fave_num).to.eql(1)
expect(state.timelines.public.maxId).to.eq(favorite.id)
// If something is favorited by the current user, it also sets the 'favorited' property but does not increment counter to avoid over-counting. Counter is incremented (updated, really) via response to the favorite request.
const user = {
id: '1'
}
const ownFavorite = {
id: '3',
type: 'favorite',
in_reply_to_status_id: '1', // The API uses strings here...
uri: 'tag:shitposter.club,2016-08-21:fave:3895:note:773501:2016-08-21T16:52:15+00:00',
text: 'a favorited something by b',
user
}
mutations.addNewStatuses(state, { statuses: [ownFavorite], showImmediately: true, timeline: 'public', user })
expect(state.timelines.public.visibleStatuses.length).to.eql(1)
expect(state.timelines.public.visibleStatuses[0].fave_num).to.eql(1)
expect(state.timelines.public.visibleStatuses[0].favorited).to.eql(true)
})
it('keeps userId when clearing user timeline', () => {
- const state = cloneDeep(defaultState)
+ const state = defaultState()
state.timelines.user.userId = 123
mutations.clearTimeline(state, { timeline: 'user' })
expect(state.timelines.user.userId).to.eql(123)
})
describe('notifications', () => {
it('removes a notification when the notice gets removed', () => {
const user = { id: '1' }
- const state = cloneDeep(defaultState)
+ const state = defaultState()
const status = makeMockStatus({id: '1'})
const otherStatus = makeMockStatus({id: '3'})
const mentionedStatus = makeMockStatus({id: '2'})
mentionedStatus.attentions = [user]
mentionedStatus.uri = 'xxx'
otherStatus.attentions = [user]
const deletion = makeMockStatus({id: '4', type: 'deletion'})
deletion.text = 'Dolus deleted notice {{tag:gs.smuglo.li,2016-11-18:noticeId=1038007:objectType=note}}.'
deletion.uri = 'xxx'
mutations.addNewStatuses(state, { statuses: [status, otherStatus], user })
mutations.addNewNotifications(
state,
{
notifications: [{
from_profile: { id: '2' },
id: '998',
type: 'mention',
status: otherStatus,
action: otherStatus,
seen: false
}]
})
expect(state.notifications.data.length).to.eql(1)
mutations.addNewNotifications(
state,
{
notifications: [{
from_profile: { id: '2' },
id: '999',
type: 'mention',
status: mentionedStatus,
action: mentionedStatus,
seen: false
}]
})
mutations.addNewStatuses(state, { statuses: [mentionedStatus], user })
expect(state.allStatuses.length).to.eql(3)
expect(state.notifications.data.length).to.eql(2)
expect(state.notifications.data[1].status).to.eql(mentionedStatus)
expect(state.notifications.data[1].action).to.eql(mentionedStatus)
expect(state.notifications.data[1].type).to.eql('mention')
mutations.addNewStatuses(state, { statuses: [deletion], user })
expect(state.allStatuses.length).to.eql(2)
expect(state.notifications.data.length).to.eql(1)
})
})
})

File Metadata

Mime Type
text/x-diff
Expires
Sun, Aug 9, 9:29 AM (1 d, 11 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1724396
Default Alt Text
(293 KB)

Event Timeline