Merge remote-tracking branch 'upstream/develop' into minimal-scopes-mode

* upstream/develop: (173 commits)
  Fix: Change condition
  fix typo
  update store according to retweeted status
  #433 - update sort by for conversation
  display replies_count right after reply icon
  expose replies_count from mastodon api
  Apparently, MastoAPI gives status in ancestors if you try opening a repeat...
  make side drawer use gesture service and fix its animations
  review/remove error hiding
  errata
  review
  #433 - sort conversation for retweets and clean up
  Revert "Merge branch 'revert-987b5162' into 'develop'"
  Revert "Merge branch 'mastoapi/friends-tl' into 'develop'"
  Add await to login action'
  Remove console log
  Fix warnings in user profile routing
  Add tests for gesture service, fix bug with perpendicular directions
  #255 - clean up autocomplete form
  #255 - clean up user settings page with self-closing html tags
  ...
This commit is contained in:
Henry Jameson 2019-03-30 12:31:50 +02:00
commit 9f4a9bff46
115 changed files with 3612 additions and 1222 deletions

View file

@ -8,6 +8,7 @@ import WhoToFollowPanel from './components/who_to_follow_panel/who_to_follow_pan
import ChatPanel from './components/chat_panel/chat_panel.vue' import ChatPanel from './components/chat_panel/chat_panel.vue'
import MediaModal from './components/media_modal/media_modal.vue' import MediaModal from './components/media_modal/media_modal.vue'
import SideDrawer from './components/side_drawer/side_drawer.vue' import SideDrawer from './components/side_drawer/side_drawer.vue'
import MobilePostStatusModal from './components/mobile_post_status_modal/mobile_post_status_modal.vue'
import { unseenNotificationsFromStore } from './services/notification_utils/notification_utils' import { unseenNotificationsFromStore } from './services/notification_utils/notification_utils'
export default { export default {
@ -22,7 +23,8 @@ export default {
WhoToFollowPanel, WhoToFollowPanel,
ChatPanel, ChatPanel,
MediaModal, MediaModal,
SideDrawer SideDrawer,
MobilePostStatusModal
}, },
data: () => ({ data: () => ({
mobileActivePanel: 'timeline', mobileActivePanel: 'timeline',

View file

@ -154,7 +154,7 @@ input, textarea, .select {
background: transparent; background: transparent;
border: none; border: none;
color: $fallback--text; color: $fallback--text;
color: var(--text, $fallback--text); color: var(--inputText, --text, $fallback--text);
margin: 0; margin: 0;
padding: 0 2em 0 .2em; padding: 0 2em 0 .2em;
font-family: sans-serif; font-family: sans-serif;
@ -671,6 +671,31 @@ nav {
border-radius: var(--inputRadius, $fallback--inputRadius); border-radius: var(--inputRadius, $fallback--inputRadius);
} }
@keyframes modal-background-fadein {
from {
background-color: rgba(0, 0, 0, 0);
}
to {
background-color: rgba(0, 0, 0, 0.5);
}
}
.modal-view {
z-index: 1000;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: center;
align-items: center;
overflow: auto;
animation-duration: 0.2s;
background-color: rgba(0, 0, 0, 0.5);
animation-name: modal-background-fadein;
}
.button-icon { .button-icon {
font-size: 1.2em; font-size: 1.2em;
} }
@ -742,3 +767,54 @@ nav {
.btn.btn-default { .btn.btn-default {
min-height: 28px; min-height: 28px;
} }
.autocomplete {
&-panel {
position: relative;
&-body {
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);
}
}
&-item {
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;
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);
}
}
}

View file

@ -50,6 +50,7 @@
<media-modal></media-modal> <media-modal></media-modal>
</div> </div>
<chat-panel :floating="true" v-if="currentUser && chat" class="floating-chat mobile-hidden"></chat-panel> <chat-panel :floating="true" v-if="currentUser && chat" class="floating-chat mobile-hidden"></chat-panel>
<MobilePostStatusModal />
</div> </div>
</template> </template>

View file

@ -4,10 +4,11 @@ import routes from './routes'
import App from '../App.vue' import App from '../App.vue'
const afterStoreSetup = ({ store, i18n }) => { const getStatusnetConfig = async ({ store }) => {
window.fetch('/api/statusnet/config.json') try {
.then((res) => res.json()) const res = await window.fetch('/api/statusnet/config.json')
.then((data) => { if (res.ok) {
const data = await res.json()
const { name, closed: registrationClosed, textlimit, uploadlimit, server, vapidPublicKey } = data.site const { name, closed: registrationClosed, textlimit, uploadlimit, server, vapidPublicKey } = data.site
store.dispatch('setInstanceOption', { name: 'name', value: name }) store.dispatch('setInstanceOption', { name: 'name', value: name })
@ -28,140 +29,168 @@ const afterStoreSetup = ({ store, i18n }) => {
store.dispatch('setInstanceOption', { name: 'vapidPublicKey', value: vapidPublicKey }) store.dispatch('setInstanceOption', { name: 'vapidPublicKey', value: vapidPublicKey })
} }
var apiConfig = data.site.pleromafe return data.site.pleromafe
} else {
throw (res)
}
} catch (error) {
console.error('Could not load statusnet config, potentially fatal')
console.error(error)
}
}
window.fetch('/static/config.json') const getStaticConfig = async () => {
.then((res) => res.json()) try {
.catch((err) => { const res = await window.fetch('/static/config.json')
console.warn('Failed to load static/config.json, continuing without it.') if (res.ok) {
console.warn(err) return res.json()
return {} } else {
}) throw (res)
.then((staticConfig) => { }
const overrides = window.___pleromafe_dev_overrides || {} } catch (error) {
const env = window.___pleromafe_mode.NODE_ENV console.warn('Failed to load static/config.json, continuing without it.')
console.warn(error)
return {}
}
}
// This takes static config and overrides properties that are present in apiConfig const setSettings = async ({ apiConfig, staticConfig, store }) => {
let config = {} const overrides = window.___pleromafe_dev_overrides || {}
if (overrides.staticConfigPreference && env === 'development') { const env = window.___pleromafe_mode.NODE_ENV
console.warn('OVERRIDING API CONFIG WITH STATIC CONFIG')
config = Object.assign({}, apiConfig, staticConfig)
} else {
config = Object.assign({}, staticConfig, apiConfig)
}
const copyInstanceOption = (name) => { // This takes static config and overrides properties that are present in apiConfig
store.dispatch('setInstanceOption', {name, value: config[name]}) 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)
}
copyInstanceOption('nsfwCensorImage') const copyInstanceOption = (name) => {
copyInstanceOption('background') store.dispatch('setInstanceOption', { name, value: config[name] })
copyInstanceOption('hidePostStats') }
copyInstanceOption('hideUserStats')
copyInstanceOption('hideFilteredStatuses')
copyInstanceOption('logo')
store.dispatch('setInstanceOption', { copyInstanceOption('nsfwCensorImage')
name: 'logoMask', copyInstanceOption('background')
value: typeof config.logoMask === 'undefined' copyInstanceOption('hidePostStats')
? true copyInstanceOption('hideUserStats')
: config.logoMask copyInstanceOption('hideFilteredStatuses')
}) copyInstanceOption('logo')
store.dispatch('setInstanceOption', { store.dispatch('setInstanceOption', {
name: 'logoMargin', name: 'logoMask',
value: typeof config.logoMargin === 'undefined' value: typeof config.logoMask === 'undefined'
? 0 ? true
: config.logoMargin : config.logoMask
}) })
copyInstanceOption('redirectRootNoLogin') store.dispatch('setInstanceOption', {
copyInstanceOption('redirectRootLogin') name: 'logoMargin',
copyInstanceOption('showInstanceSpecificPanel') value: typeof config.logoMargin === 'undefined'
copyInstanceOption('minimalScopesMode') ? 0
copyInstanceOption('formattingOptionsEnabled') : config.logoMargin
copyInstanceOption('collapseMessageWithSubject') })
copyInstanceOption('loginMethod')
copyInstanceOption('scopeCopy')
copyInstanceOption('subjectLineBehavior')
copyInstanceOption('postContentType')
copyInstanceOption('alwaysShowSubjectInput')
copyInstanceOption('noAttachmentLinks')
copyInstanceOption('showFeaturesPanel')
if ((config.chatDisabled)) { copyInstanceOption('redirectRootNoLogin')
store.dispatch('disableChat') copyInstanceOption('redirectRootLogin')
} else { copyInstanceOption('showInstanceSpecificPanel')
store.dispatch('initializeSocket') copyInstanceOption('minimalScopesMode')
} copyInstanceOption('formattingOptionsEnabled')
copyInstanceOption('hideMutedPosts')
copyInstanceOption('collapseMessageWithSubject')
copyInstanceOption('loginMethod')
copyInstanceOption('scopeCopy')
copyInstanceOption('subjectLineBehavior')
copyInstanceOption('postContentType')
copyInstanceOption('alwaysShowSubjectInput')
copyInstanceOption('noAttachmentLinks')
copyInstanceOption('showFeaturesPanel')
return store.dispatch('setTheme', config['theme']) if ((config.chatDisabled)) {
}) store.dispatch('disableChat')
.then(() => { } else {
const router = new VueRouter({ store.dispatch('initializeSocket')
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 */ return store.dispatch('setTheme', config['theme'])
new Vue({ }
router,
store,
i18n,
el: '#app',
render: h => h(App)
})
})
})
window.fetch('/static/terms-of-service.html') const getTOS = async ({ store }) => {
.then((res) => res.text()) try {
.then((html) => { const res = await window.fetch('/static/terms-of-service.html')
if (res.ok) {
const html = await res.text()
store.dispatch('setInstanceOption', { name: 'tos', value: html }) store.dispatch('setInstanceOption', { name: 'tos', value: html })
}) } else {
throw (res)
}
} catch (e) {
console.warn("Can't load TOS")
console.warn(e)
}
}
window.fetch('/api/pleroma/emoji.json') const getInstancePanel = async ({ store }) => {
.then( try {
(res) => res.json() const res = await window.fetch('/instance/panel.html')
.then( if (res.ok) {
(values) => { const html = await res.text()
const emoji = Object.keys(values).map((key) => { store.dispatch('setInstanceOption', { name: 'instanceSpecificPanelContent', value: html })
return { shortcode: key, image_url: values[key] } } else {
}) throw (res)
store.dispatch('setInstanceOption', { name: 'customEmoji', value: emoji }) }
store.dispatch('setInstanceOption', { name: 'pleromaBackend', value: true }) } catch (e) {
}, console.warn("Can't load instance panel")
(failure) => { console.warn(e)
store.dispatch('setInstanceOption', { name: 'pleromaBackend', value: false }) }
} }
),
(error) => console.log(error)
)
window.fetch('/static/emoji.json') const getStaticEmoji = async ({ store }) => {
.then((res) => res.json()) try {
.then((values) => { const res = await window.fetch('/static/emoji.json')
if (res.ok) {
const values = await res.json()
const emoji = Object.keys(values).map((key) => { const emoji = Object.keys(values).map((key) => {
return { shortcode: key, image_url: false, 'utf': values[key] } return { shortcode: key, image_url: false, 'utf': values[key] }
}) })
store.dispatch('setInstanceOption', { name: 'emoji', value: emoji }) store.dispatch('setInstanceOption', { name: 'emoji', value: emoji })
}) } else {
throw (res)
}
} catch (e) {
console.warn("Can't load static emoji")
console.warn(e)
}
}
window.fetch('/instance/panel.html') // This is also used to indicate if we have a 'pleroma backend' or not.
.then((res) => res.text()) // Somewhat weird, should probably be somewhere else.
.then((html) => { const getCustomEmoji = async ({ store }) => {
store.dispatch('setInstanceOption', { name: 'instanceSpecificPanelContent', value: html }) try {
}) const res = await window.fetch('/api/pleroma/emoji.json')
if (res.ok) {
const values = await res.json()
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 })
} else {
throw (res)
}
} catch (e) {
store.dispatch('setInstanceOption', { name: 'pleromaBackend', value: false })
console.warn("Can't load custom emojis, maybe not a Pleroma instance?")
console.warn(e)
}
}
window.fetch('/nodeinfo/2.0.json') const getNodeInfo = async ({ store }) => {
.then((res) => res.json()) try {
.then((data) => { const res = await window.fetch('/nodeinfo/2.0.json')
if (res.ok) {
const data = await res.json()
const metadata = data.metadata const metadata = data.metadata
const features = metadata.features const features = metadata.features
@ -170,11 +199,70 @@ const afterStoreSetup = ({ store, i18n }) => {
store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') }) store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') })
store.dispatch('setInstanceOption', { name: 'restrictedNicknames', value: metadata.restrictedNicknames }) store.dispatch('setInstanceOption', { name: 'restrictedNicknames', value: metadata.restrictedNicknames })
store.dispatch('setInstanceOption', { name: 'postFormats', value: metadata.postFormats })
const suggestions = metadata.suggestions const suggestions = metadata.suggestions
store.dispatch('setInstanceOption', { name: 'suggestionsEnabled', value: suggestions.enabled }) store.dispatch('setInstanceOption', { name: 'suggestionsEnabled', value: suggestions.enabled })
store.dispatch('setInstanceOption', { name: 'suggestionsWeb', value: suggestions.web }) store.dispatch('setInstanceOption', { name: 'suggestionsWeb', value: suggestions.web })
const software = data.software
store.dispatch('setInstanceOption', { name: 'backendVersion', value: software.version })
const frontendVersion = window.___pleromafe_commit_hash
store.dispatch('setInstanceOption', { name: 'frontendVersion', value: frontendVersion })
} else {
throw (res)
}
} catch (e) {
console.warn('Could not load nodeinfo')
console.warn(e)
}
}
const afterStoreSetup = async ({ store, i18n }) => {
if (store.state.config.customTheme) {
// This is a hack to deal with async loading of config.json and themes
// See: style_setter.js, setPreset()
window.themeLoaded = true
store.dispatch('setOption', {
name: 'customTheme',
value: store.state.config.customTheme
}) })
}
const apiConfig = await getStatusnetConfig({ store })
const staticConfig = await getStaticConfig()
await setSettings({ store, apiConfig, staticConfig })
await getTOS({ store })
await getInstancePanel({ store })
await getStaticEmoji({ store })
await getCustomEmoji({ store })
await getNodeInfo({ store })
// Now we have the server settings and can try logging in
if (store.state.oauth.token) {
await store.dispatch('loginUser', store.state.oauth.token)
}
const 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 */
return new Vue({
router,
store,
i18n,
el: '#app',
render: h => h(App)
})
} }
export default afterStoreSetup export default afterStoreSetup

View file

@ -13,7 +13,6 @@ import FollowRequests from 'components/follow_requests/follow_requests.vue'
import OAuthCallback from 'components/oauth_callback/oauth_callback.vue' import OAuthCallback from 'components/oauth_callback/oauth_callback.vue'
import UserSearch from 'components/user_search/user_search.vue' import UserSearch from 'components/user_search/user_search.vue'
import Notifications from 'components/notifications/notifications.vue' import Notifications from 'components/notifications/notifications.vue'
import UserPanel from 'components/user_panel/user_panel.vue'
import LoginForm from 'components/login_form/login_form.vue' import LoginForm from 'components/login_form/login_form.vue'
import ChatPanel from 'components/chat_panel/chat_panel.vue' import ChatPanel from 'components/chat_panel/chat_panel.vue'
import WhoToFollow from 'components/who_to_follow/who_to_follow.vue' import WhoToFollow from 'components/who_to_follow/who_to_follow.vue'
@ -43,7 +42,6 @@ export default (store) => {
{ name: 'friend-requests', path: '/friend-requests', component: FollowRequests }, { name: 'friend-requests', path: '/friend-requests', component: FollowRequests },
{ name: 'user-settings', path: '/user-settings', component: UserSettings }, { name: 'user-settings', path: '/user-settings', component: UserSettings },
{ name: 'notifications', path: '/:username/notifications', component: Notifications }, { name: 'notifications', path: '/:username/notifications', component: Notifications },
{ name: 'new-status', path: '/:username/new-status', component: UserPanel },
{ name: 'login', path: '/login', component: LoginForm }, { name: 'login', path: '/login', component: LoginForm },
{ name: 'chat', path: '/chat', component: ChatPanel, props: () => ({ floating: false }) }, { name: 'chat', path: '/chat', component: ChatPanel, props: () => ({ floating: false }) },
{ name: 'oauth-callback', path: '/oauth-callback', component: OAuthCallback, props: (route) => ({ code: route.query.code }) }, { name: 'oauth-callback', path: '/oauth-callback', component: OAuthCallback, props: (route) => ({ code: route.query.code }) },

View file

@ -160,6 +160,7 @@
.hider { .hider {
position: absolute; position: absolute;
right: 0;
white-space: nowrap; white-space: nowrap;
margin: 10px; margin: 10px;
padding: 5px; padding: 5px;

View file

@ -1,4 +1,4 @@
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 UserAvatar from '../user_avatar/user_avatar.vue'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
@ -12,7 +12,7 @@ const BasicUserCard = {
} }
}, },
components: { components: {
UserCardContent, UserCard,
UserAvatar UserAvatar
}, },
methods: { methods: {

View file

@ -1,18 +1,18 @@
<template> <template>
<div class="user-card"> <div class="basic-user-card">
<router-link :to="userProfileLink(user)"> <router-link :to="userProfileLink(user)">
<UserAvatar class="avatar" @click.prevent.native="toggleUserExpanded" :src="user.profile_image_url"/> <UserAvatar class="avatar" @click.prevent.native="toggleUserExpanded" :src="user.profile_image_url"/>
</router-link> </router-link>
<div class="user-card-expanded-content" v-if="userExpanded"> <div class="basic-user-card-expanded-content" v-if="userExpanded">
<user-card-content :user="user" :switcher="false"></user-card-content> <UserCard :user="user" :rounded="true" :bordered="true"/>
</div> </div>
<div class="user-card-collapsed-content" v-else> <div class="basic-user-card-collapsed-content" v-else>
<div :title="user.name" class="user-card-user-name"> <div :title="user.name" class="basic-user-card-user-name">
<span v-if="user.name_html" v-html="user.name_html"></span> <span v-if="user.name_html" class="basic-user-card-user-name-value" v-html="user.name_html"></span>
<span v-else>{{ user.name }}</span> <span v-else class="basic-user-card-user-name-value">{{ user.name }}</span>
</div> </div>
<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}} @{{user.screen_name}}
</router-link> </router-link>
</div> </div>
@ -26,15 +26,15 @@
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import '../../_variables.scss';
.user-card { .basic-user-card {
display: flex; display: flex;
flex: 1 0; flex: 1 0;
margin: 0;
padding-top: 0.6em; padding-top: 0.6em;
padding-right: 1em; padding-right: 1em;
padding-bottom: 0.6em; padding-bottom: 0.6em;
padding-left: 1em; padding-left: 1em;
border-bottom: 1px solid; border-bottom: 1px solid;
margin: 0;
border-bottom-color: $fallback--border; border-bottom-color: $fallback--border;
border-bottom-color: var(--border, $fallback--border); border-bottom-color: var(--border, $fallback--border);
@ -52,28 +52,19 @@
width: 16px; width: 16px;
vertical-align: middle; vertical-align: middle;
} }
&-value {
display: inline-block;
max-width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
} }
&-expanded-content { &-expanded-content {
flex: 1; flex: 1;
margin-left: 0.7em; 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> </style>

View file

@ -9,7 +9,7 @@ const BlockCard = {
}, },
computed: { computed: {
user () { user () {
return this.$store.getters.userById(this.userId) return this.$store.getters.findUser(this.userId)
}, },
blocked () { blocked () {
return this.user.statusnet_blocking return this.user.statusnet_blocking

View file

@ -1,5 +1,4 @@
import Conversation from '../conversation/conversation.vue' import Conversation from '../conversation/conversation.vue'
import { find } from 'lodash'
const conversationPage = { const conversationPage = {
components: { components: {
@ -8,8 +7,8 @@ const conversationPage = {
computed: { computed: {
statusoid () { statusoid () {
const id = this.$route.params.id const id = this.$route.params.id
const statuses = this.$store.state.statuses.allStatuses const statuses = this.$store.state.statuses.allStatusesObject
const status = find(statuses, {id}) const status = statuses[id]
return status return status
} }

View file

@ -1,5 +1,9 @@
<template> <template>
<conversation :collapsable="false" :statusoid="statusoid"></conversation> <conversation
:collapsable="false"
isPage="true"
:statusoid="statusoid"
></conversation>
</template> </template>
<script src="./conversation-page.js"></script> <script src="./conversation-page.js"></script>

View file

@ -1,9 +1,12 @@
import { reduce, filter } from 'lodash' import { reduce, filter, findIndex } from 'lodash'
import { set } from 'vue'
import Status from '../status/status.vue' import Status from '../status/status.vue'
const sortById = (a, b) => { const sortById = (a, b) => {
const seqA = Number(a.id) const idA = a.type === 'retweet' ? a.retweeted_status.id : a.id
const seqB = Number(b.id) const idB = b.type === 'retweet' ? b.retweeted_status.id : b.id
const seqA = Number(idA)
const seqB = Number(idB)
const isSeqA = !Number.isNaN(seqA) const isSeqA = !Number.isNaN(seqA)
const isSeqB = !Number.isNaN(seqB) const isSeqB = !Number.isNaN(seqB)
if (isSeqA && isSeqB) { if (isSeqA && isSeqB) {
@ -13,29 +16,53 @@ const sortById = (a, b) => {
} else if (!isSeqA && isSeqB) { } else if (!isSeqA && isSeqB) {
return 1 return 1
} else { } else {
return a.id < b.id ? -1 : 1 return idA < idB ? -1 : 1
} }
} }
const sortAndFilterConversation = (conversation) => { const sortAndFilterConversation = (conversation, statusoid) => {
conversation = filter(conversation, (status) => status.type !== 'retweet') if (statusoid.type === 'retweet') {
conversation = filter(
conversation,
(status) => (status.type === 'retweet' || status.id !== statusoid.retweeted_status.id)
)
} else {
conversation = filter(conversation, (status) => status.type !== 'retweet')
}
return conversation.filter(_ => _).sort(sortById) return conversation.filter(_ => _).sort(sortById)
} }
const conversation = { const conversation = {
data () { data () {
return { return {
highlight: null highlight: null,
expanded: false,
converationStatusIds: []
} }
}, },
props: [ props: [
'statusoid', 'statusoid',
'collapsable' 'collapsable',
'isPage'
], ],
created () {
if (this.isPage) {
this.fetchConversation()
}
},
computed: { computed: {
status () { status () {
return this.statusoid return this.statusoid
}, },
idsToShow () {
if (this.converationStatusIds.length > 0) {
return this.converationStatusIds
} else if (this.statusId) {
return [this.statusId]
} else {
return []
}
},
statusId () { statusId () {
if (this.statusoid.retweeted_status) { if (this.statusoid.retweeted_status) {
return this.statusoid.retweeted_status.id return this.statusoid.retweeted_status.id
@ -48,10 +75,22 @@ const conversation = {
return [] return []
} }
const conversationId = this.status.statusnet_conversation_id if (!this.isExpanded) {
const statuses = this.$store.state.statuses.allStatuses return [this.status]
const conversation = filter(statuses, { statusnet_conversation_id: conversationId }) }
return sortAndFilterConversation(conversation)
const statusesObject = this.$store.state.statuses.allStatusesObject
const conversation = this.idsToShow.reduce((acc, id) => {
acc.push(statusesObject[id])
return acc
}, [])
const statusIndex = findIndex(conversation, { id: this.statusId })
if (statusIndex !== -1) {
conversation[statusIndex] = this.status
}
return sortAndFilterConversation(conversation, this.status)
}, },
replies () { replies () {
let i = 1 let i = 1
@ -69,23 +108,34 @@ const conversation = {
i++ i++
return result return result
}, {}) }, {})
},
isExpanded () {
return this.expanded || this.isPage
} }
}, },
components: { components: {
Status Status
}, },
created () {
this.fetchConversation()
},
watch: { watch: {
'$route': 'fetchConversation' '$route': 'fetchConversation',
expanded (value) {
if (value) {
this.fetchConversation()
}
}
}, },
methods: { methods: {
fetchConversation () { fetchConversation () {
if (this.status) { if (this.status) {
const conversationId = this.status.statusnet_conversation_id this.$store.state.api.backendInteractor.fetchConversation({id: this.status.id})
this.$store.state.api.backendInteractor.fetchConversation({id: conversationId}) .then(({ancestors, descendants}) => {
.then((statuses) => this.$store.dispatch('addNewStatuses', { statuses })) this.$store.dispatch('addNewStatuses', { statuses: ancestors })
this.$store.dispatch('addNewStatuses', { statuses: descendants })
set(this, 'converationStatusIds', [].concat(
ancestors.map(_ => _.id).filter(_ => _ !== this.statusId),
this.statusId,
descendants.map(_ => _.id).filter(_ => _ !== this.statusId)))
})
.then(() => this.setHighlight(this.statusId)) .then(() => this.setHighlight(this.statusId))
} else { } else {
const id = this.$route.params.id const id = this.$route.params.id
@ -98,10 +148,19 @@ const conversation = {
return this.replies[id] || [] return this.replies[id] || []
}, },
focused (id) { focused (id) {
return id === this.statusId return (this.isExpanded) && id === this.status.id
}, },
setHighlight (id) { setHighlight (id) {
this.highlight = id this.highlight = id
},
getHighlight () {
return this.isExpanded ? this.highlight : null
},
toggleExpanded () {
this.expanded = !this.expanded
if (!this.expanded) {
this.setHighlight(null)
}
} }
} }
} }

View file

@ -1,26 +1,42 @@
<template> <template>
<div class="timeline panel panel-default"> <div class="timeline panel-default" :class="[isExpanded ? 'panel' : 'panel-disabled']">
<div class="panel-heading conversation-heading"> <div v-if="isExpanded" class="panel-heading conversation-heading">
<span class="title"> {{ $t('timeline.conversation') }} </span> <span class="title"> {{ $t('timeline.conversation') }} </span>
<span v-if="collapsable"> <span v-if="collapsable">
<a href="#" @click.prevent="$emit('toggleExpanded')">{{ $t('timeline.collapse') }}</a> <a href="#" @click.prevent="toggleExpanded">{{ $t('timeline.collapse') }}</a>
</span> </span>
</div> </div>
<div class="panel-body"> <status
<div class="timeline"> v-for="status in conversation"
<status @goto="setHighlight"
v-for="status in conversation" @toggleExpanded="toggleExpanded"
@goto="setHighlight" :key="status.id" :key="status.id"
:inlineExpanded="collapsable" :statusoid="status" :inlineExpanded="collapsable"
:expandable='false' :focused="focused(status.id)" :statusoid="status"
:inConversation='true' :expandable='!expanded'
:highlight="highlight" :focused="focused(status.id)"
:replies="getReplies(status.id)" :inConversation="isExpanded"
class="status-fadein"> :highlight="getHighlight()"
</status> :replies="getReplies(status.id)"
</div> class="status-fadein panel-body"
</div> />
</div> </div>
</template> </template>
<script src="./conversation.js"></script> <script src="./conversation.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.timeline {
.panel-disabled {
.status-el {
border-left: none;
border-bottom-width: 1px;
border-bottom-style: solid;
border-color: var(--border, $fallback--border);
border-radius: 0;
}
}
}
</style>

View file

@ -0,0 +1,107 @@
import Completion from '../../services/completion/completion.js'
import { take, filter, map } from 'lodash'
const EmojiInput = {
props: [
'value',
'placeholder',
'type',
'classname'
],
data () {
return {
highlighted: 0,
caret: 0
}
},
computed: {
suggestions () {
const firstchar = this.textAtCaret.charAt(0)
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) => ({
shortcode: `:${shortcode}:`,
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.value, this.caret - 1) || {}
return word
},
emoji () {
return this.$store.state.instance.emoji || []
},
customEmoji () {
return this.$store.state.instance.customEmoji || []
}
},
methods: {
replace (replacement) {
const newValue = Completion.replaceWord(this.value, this.wordAtCaret, replacement)
this.$emit('input', newValue)
this.caret = 0
},
replaceEmoji (e) {
const len = this.suggestions.length || 0
if (this.textAtCaret === ':' || e.ctrlKey) { return }
if (len > 0) {
e.preventDefault()
const emoji = this.suggestions[this.highlighted]
const replacement = emoji.utf || (emoji.shortcode + ' ')
const newValue = Completion.replaceWord(this.value, this.wordAtCaret, replacement)
this.$emit('input', newValue)
this.caret = 0
this.highlighted = 0
}
},
cycleBackward (e) {
const len = this.suggestions.length || 0
if (len > 0) {
e.preventDefault()
this.highlighted -= 1
if (this.highlighted < 0) {
this.highlighted = this.suggestions.length - 1
}
} else {
this.highlighted = 0
}
},
cycleForward (e) {
const len = this.suggestions.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
}
},
onKeydown (e) {
e.stopPropagation()
},
onInput (e) {
this.$emit('input', e.target.value)
},
setCaret ({target: {selectionStart}}) {
this.caret = selectionStart
}
}
}
export default EmojiInput

View file

@ -0,0 +1,64 @@
<template>
<div class="emoji-input">
<input
v-if="type !== 'textarea'"
:class="classname"
:type="type"
:value="value"
:placeholder="placeholder"
@input="onInput"
@click="setCaret"
@keyup="setCaret"
@keydown="onKeydown"
@keydown.down="cycleForward"
@keydown.up="cycleBackward"
@keydown.shift.tab="cycleBackward"
@keydown.tab="cycleForward"
@keydown.enter="replaceEmoji"
/>
<textarea
v-else
:class="classname"
:value="value"
:placeholder="placeholder"
@input="onInput"
@click="setCaret"
@keyup="setCaret"
@keydown="onKeydown"
@keydown.down="cycleForward"
@keydown.up="cycleBackward"
@keydown.shift.tab="cycleBackward"
@keydown.tab="cycleForward"
@keydown.enter="replaceEmoji"
></textarea>
<div class="autocomplete-panel" v-if="suggestions">
<div class="autocomplete-panel-body">
<div
v-for="(emoji, index) in suggestions"
:key="index"
@click="replace(emoji.utf || (emoji.shortcode + ' '))"
class="autocomplete-item"
:class="{ highlighted: emoji.highlighted }"
>
<span v-if="emoji.img">
<img :src="emoji.img" />
</span>
<span v-else>{{emoji.utf}}</span>
<span>{{emoji.shortcode}}</span>
</div>
</div>
</div>
</div>
</template>
<script src="./emoji-input.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.emoji-input {
.form-control {
width: 100%;
}
}
</style>

View file

@ -1,4 +1,5 @@
import BasicUserCard from '../basic_user_card/basic_user_card.vue' import BasicUserCard from '../basic_user_card/basic_user_card.vue'
import RemoteFollow from '../remote_follow/remote_follow.vue'
import { requestFollow, requestUnfollow } from '../../services/follow_manipulate/follow_manipulate' import { requestFollow, requestUnfollow } from '../../services/follow_manipulate/follow_manipulate'
const FollowCard = { const FollowCard = {
@ -14,13 +15,17 @@ const FollowCard = {
} }
}, },
components: { components: {
BasicUserCard BasicUserCard,
RemoteFollow
}, },
computed: { computed: {
isMe () { return this.$store.state.users.currentUser.id === this.user.id }, isMe () { return this.$store.state.users.currentUser.id === this.user.id },
following () { return this.updated ? this.updated.following : this.user.following }, following () { return this.updated ? this.updated.following : this.user.following },
showFollow () { showFollow () {
return !this.following || this.updated && !this.updated.following return !this.following || this.updated && !this.updated.following
},
loggedIn () {
return this.$store.state.users.currentUser
} }
}, },
methods: { methods: {

View file

@ -4,9 +4,12 @@
<span class="faint" v-if="!noFollowsYou && user.follows_you"> <span class="faint" v-if="!noFollowsYou && user.follows_you">
{{ isMe ? $t('user_card.its_you') : $t('user_card.follows_you') }} {{ isMe ? $t('user_card.its_you') : $t('user_card.follows_you') }}
</span> </span>
<div class="follow-card-follow-button" v-if="showFollow && !loggedIn">
<RemoteFollow :user="user" />
</div>
<button <button
v-if="showFollow" v-if="showFollow && loggedIn"
class="btn btn-default" class="btn btn-default follow-card-follow-button"
@click="followUser" @click="followUser"
:disabled="inProgress" :disabled="inProgress"
:title="requestSent ? $t('user_card.follow_again') : ''" :title="requestSent ? $t('user_card.follow_again') : ''"
@ -21,7 +24,7 @@
{{ $t('user_card.follow') }} {{ $t('user_card.follow') }}
</template> </template>
</button> </button>
<button v-if="following" class="btn btn-default pressed" @click="unfollowUser" :disabled="inProgress"> <button v-if="following" class="btn btn-default follow-card-follow-button pressed" @click="unfollowUser" :disabled="inProgress">
<template v-if="inProgress"> <template v-if="inProgress">
{{ $t('user_card.follow_progress') }} {{ $t('user_card.follow_progress') }}
</template> </template>
@ -36,15 +39,17 @@
<script src="./follow_card.js"></script> <script src="./follow_card.js"></script>
<style lang="scss"> <style lang="scss">
.follow-card-content-container { .follow-card {
flex-shrink: 0; &-content-container {
display: flex; flex-shrink: 0;
flex-direction: row; display: flex;
justify-content: space-between; flex-direction: row;
flex-wrap: wrap; justify-content: space-between;
line-height: 1.5em; flex-wrap: wrap;
line-height: 1.5em;
}
.btn { &-follow-button {
margin-top: 0.5em; margin-top: 0.5em;
margin-left: auto; margin-left: auto;
width: 10em; width: 10em;

View file

@ -27,7 +27,6 @@
align-content: stretch; align-content: stretch;
flex-grow: 1; flex-grow: 1;
margin-top: 0.5em; margin-top: 0.5em;
margin-bottom: 0.25em;
.attachments, .attachment { .attachments, .attachment {
margin: 0 0.5em 0 0; margin: 0 0.5em 0 0;

View file

@ -31,6 +31,9 @@ const ImageCropper = {
saveButtonLabel: { saveButtonLabel: {
type: String type: String
}, },
saveWithoutCroppingButtonlabel: {
type: String
},
cancelButtonLabel: { cancelButtonLabel: {
type: String type: String
} }
@ -48,6 +51,9 @@ const ImageCropper = {
saveText () { saveText () {
return this.saveButtonLabel || this.$t('image_cropper.save') return this.saveButtonLabel || this.$t('image_cropper.save')
}, },
saveWithoutCroppingText () {
return this.saveWithoutCroppingButtonlabel || this.$t('image_cropper.save_without_cropping')
},
cancelText () { cancelText () {
return this.cancelButtonLabel || this.$t('image_cropper.cancel') return this.cancelButtonLabel || this.$t('image_cropper.cancel')
}, },
@ -67,7 +73,19 @@ const ImageCropper = {
submit () { submit () {
this.submitting = true this.submitting = true
this.avatarUploadError = null this.avatarUploadError = null
this.submitHandler(this.cropper, this.filename) this.submitHandler(this.cropper, this.file)
.then(() => this.destroy())
.catch((err) => {
this.submitError = err
})
.finally(() => {
this.submitting = false
})
},
submitWithoutCropping () {
this.submitting = true
this.avatarUploadError = null
this.submitHandler(false, this.dataUrl)
.then(() => this.destroy()) .then(() => this.destroy())
.catch((err) => { .catch((err) => {
this.submitError = err this.submitError = err
@ -88,14 +106,14 @@ const ImageCropper = {
readFile () { readFile () {
const fileInput = this.$refs.input const fileInput = this.$refs.input
if (fileInput.files != null && fileInput.files[0] != null) { if (fileInput.files != null && fileInput.files[0] != null) {
this.file = fileInput.files[0]
let reader = new window.FileReader() let reader = new window.FileReader()
reader.onload = (e) => { reader.onload = (e) => {
this.dataUrl = e.target.result this.dataUrl = e.target.result
this.$emit('open') this.$emit('open')
} }
reader.readAsDataURL(fileInput.files[0]) reader.readAsDataURL(this.file)
this.filename = fileInput.files[0].name || 'unknown' this.$emit('changed', this.file, reader)
this.$emit('changed', fileInput.files[0], reader)
} }
}, },
clearError () { clearError () {

View file

@ -7,6 +7,7 @@
<div class="image-cropper-buttons-wrapper"> <div class="image-cropper-buttons-wrapper">
<button class="btn" type="button" :disabled="submitting" @click="submit" v-text="saveText"></button> <button class="btn" type="button" :disabled="submitting" @click="submit" v-text="saveText"></button>
<button class="btn" type="button" :disabled="submitting" @click="destroy" v-text="cancelText"></button> <button class="btn" type="button" :disabled="submitting" @click="destroy" v-text="cancelText"></button>
<button class="btn" type="button" :disabled="submitting" @click="submitWithoutCropping" v-text="saveWithoutCroppingText"></button>
<i class="icon-spin4 animate-spin" v-if="submitting"></i> <i class="icon-spin4 animate-spin" v-if="submitting"></i>
</div> </div>
<div class="alert error" v-if="submitError"> <div class="alert error" v-if="submitError">
@ -36,7 +37,11 @@
} }
&-buttons-wrapper { &-buttons-wrapper {
margin-top: 15px; margin-top: 10px;
button {
margin-top: 5px;
}
} }
} }
</style> </style>

View file

@ -23,6 +23,7 @@
flex-direction: row; flex-direction: row;
cursor: pointer; cursor: pointer;
overflow: hidden; overflow: hidden;
margin-top: 0.5em;
.card-image { .card-image {
flex-shrink: 0; flex-shrink: 0;

View file

@ -1,5 +1,5 @@
<template> <template>
<div class="modal-view" v-if="showing" @click.prevent="hide"> <div class="modal-view media-modal-view" v-if="showing" @click.prevent="hide">
<img class="modal-image" v-if="type === 'image'" :src="currentMedia.url"></img> <img class="modal-image" v-if="type === 'image'" :src="currentMedia.url"></img>
<VideoAttachment <VideoAttachment
class="modal-image" class="modal-image"
@ -32,18 +32,7 @@
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import '../../_variables.scss';
.modal-view { .media-modal-view {
z-index: 1000;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: center;
align-items: center;
background-color: rgba(0, 0, 0, 0.5);
&:hover { &:hover {
.modal-view-button-arrow { .modal-view-button-arrow {
opacity: 0.75; opacity: 0.75;

View file

@ -20,7 +20,7 @@ const mediaUpload = {
return return
} }
const formData = new FormData() const formData = new FormData()
formData.append('media', file) formData.append('file', file)
self.$emit('uploading') self.$emit('uploading')
self.uploading = true self.uploading = true

View file

@ -0,0 +1,91 @@
import PostStatusForm from '../post_status_form/post_status_form.vue'
import { throttle } from 'lodash'
const MobilePostStatusModal = {
components: {
PostStatusForm
},
data () {
return {
hidden: false,
postFormOpen: false,
scrollingDown: false,
inputActive: false,
oldScrollPos: 0,
amountScrolled: 0
}
},
created () {
window.addEventListener('scroll', this.handleScroll)
window.addEventListener('resize', this.handleOSK)
},
destroyed () {
window.removeEventListener('scroll', this.handleScroll)
window.removeEventListener('resize', this.handleOSK)
},
computed: {
currentUser () {
return this.$store.state.users.currentUser
},
isHidden () {
return this.hidden || this.inputActive
}
},
methods: {
openPostForm () {
this.postFormOpen = true
this.hidden = true
const el = this.$el.querySelector('textarea')
this.$nextTick(function () {
el.focus()
})
},
closePostForm () {
this.postFormOpen = false
this.hidden = false
},
handleOSK () {
// This is a big hack: we're guessing from changed window sizes if the
// on-screen keyboard is active or not. This is only really important
// for phones in portrait mode and it's more important to show the button
// in normal scenarios on all phones, than it is to hide it when the
// keyboard is active.
// Guesswork based on https://www.mydevice.io/#compare-devices
// for example, iphone 4 and android phones from the same time period
const smallPhone = window.innerWidth < 350
const smallPhoneKbOpen = smallPhone && window.innerHeight < 345
const biggerPhone = !smallPhone && window.innerWidth < 450
const biggerPhoneKbOpen = biggerPhone && window.innerHeight < 560
if (smallPhoneKbOpen || biggerPhoneKbOpen) {
this.inputActive = true
} else {
this.inputActive = false
}
},
handleScroll: throttle(function () {
const scrollAmount = window.scrollY - this.oldScrollPos
const scrollingDown = scrollAmount > 0
if (scrollingDown !== this.scrollingDown) {
this.amountScrolled = 0
this.scrollingDown = scrollingDown
if (!scrollingDown) {
this.hidden = false
}
} else if (scrollingDown) {
this.amountScrolled += scrollAmount
if (this.amountScrolled > 100 && !this.hidden) {
this.hidden = true
}
}
this.oldScrollPos = window.scrollY
this.scrollingDown = scrollingDown
}, 100)
}
}
export default MobilePostStatusModal

View file

@ -0,0 +1,76 @@
<template>
<div v-if="currentUser">
<div
class="post-form-modal-view modal-view"
v-show="postFormOpen"
@click="closePostForm"
>
<div class="post-form-modal-panel panel" @click.stop="">
<div class="panel-heading">{{$t('post_status.new_status')}}</div>
<PostStatusForm class="panel-body" @posted="closePostForm"/>
</div>
</div>
<button
class="new-status-button"
:class="{ 'hidden': isHidden }"
@click="openPostForm"
>
<i class="icon-edit" />
</button>
</div>
</template>
<script src="./mobile_post_status_modal.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.post-form-modal-view {
max-height: 100%;
display: block;
}
.post-form-modal-panel {
flex-shrink: 0;
margin: 25% 0 4em 0;
width: 100%;
}
.new-status-button {
width: 5em;
height: 5em;
border-radius: 100%;
position: fixed;
bottom: 1.5em;
right: 1.5em;
// TODO: this needs its own color, it has to stand out enough and link color
// is not very optimal for this particular use.
background-color: $fallback--fg;
background-color: var(--btn, $fallback--fg);
display: flex;
justify-content: center;
align-items: center;
box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.3), 0px 4px 6px rgba(0, 0, 0, 0.3);
z-index: 10;
transition: 0.35s transform;
transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
&.hidden {
transform: translateY(150%);
}
i {
font-size: 1.5em;
color: $fallback--text;
color: var(--text, $fallback--text);
}
}
@media all and (min-width: 801px) {
.new-status-button {
display: none;
}
}
</style>

View file

@ -9,7 +9,7 @@ const MuteCard = {
}, },
computed: { computed: {
user () { user () {
return this.$store.getters.userById(this.userId) return this.$store.getters.findUser(this.userId)
}, },
muted () { muted () {
return this.user.muted return this.user.muted

View file

@ -1,6 +1,6 @@
<template> <template>
<basic-user-card :user="user"> <basic-user-card :user="user">
<template slot="secondary-area"> <div class="mute-card-content-container">
<button class="btn btn-default" @click="unmuteUser" :disabled="progress" v-if="muted"> <button class="btn btn-default" @click="unmuteUser" :disabled="progress" v-if="muted">
<template v-if="progress"> <template v-if="progress">
{{ $t('user_card.unmute_progress') }} {{ $t('user_card.unmute_progress') }}
@ -17,8 +17,18 @@
{{ $t('user_card.mute') }} {{ $t('user_card.mute') }}
</template> </template>
</button> </button>
</template> </div>
</basic-user-card> </basic-user-card>
</template> </template>
<script src="./mute_card.js"></script> <script src="./mute_card.js"></script>
<style lang="scss">
.mute-card-content-container {
margin-top: 0.5em;
text-align: right;
button {
width: 10em;
}
}
</style>

View file

@ -1,6 +1,6 @@
import Status from '../status/status.vue' import Status from '../status/status.vue'
import UserAvatar from '../user_avatar/user_avatar.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 { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
@ -13,7 +13,7 @@ const Notification = {
}, },
props: [ 'notification' ], props: [ 'notification' ],
components: { components: {
Status, UserAvatar, UserCardContent Status, UserAvatar, UserCard
}, },
methods: { methods: {
toggleUserExpanded () { toggleUserExpanded () {

View file

@ -5,9 +5,7 @@
<UserAvatar :compact="true" :betterShadow="betterShadow" :src="notification.action.user.profile_image_url_original"/> <UserAvatar :compact="true" :betterShadow="betterShadow" :src="notification.action.user.profile_image_url_original"/>
</a> </a>
<div class='notification-right'> <div class='notification-right'>
<div class="usercard notification-usercard" v-if="userExpanded"> <UserCard :user="notification.action.user" :rounded="true" :bordered="true" v-if="userExpanded"/>
<user-card-content :user="notification.action.user" :switcher="false"></user-card-content>
</div>
<span class="notification-details"> <span class="notification-details">
<div class="name-and-action"> <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-if="!!notification.action.user.name_html" :title="'@'+notification.action.user.screen_name" v-html="notification.action.user.name_html"></span>
@ -25,7 +23,11 @@
<small>{{$t('notifications.followed_you')}}</small> <small>{{$t('notifications.followed_you')}}</small>
</span> </span>
</div> </div>
<small class="timeago"><router-link v-if="notification.status" :to="{ name: 'conversation', params: { id: notification.status.id } }"><timeago :since="notification.action.created_at" :auto-update="240"></timeago></router-link></small> <div class="timeago">
<router-link v-if="notification.status" :to="{ name: 'conversation', params: { id: notification.status.id } }" class="faint-link">
<timeago :since="notification.action.created_at" :auto-update="240"></timeago>
</router-link>
</div>
</span> </span>
<div class="follow-text" v-if="notification.type === 'follow'"> <div class="follow-text" v-if="notification.type === 'follow'">
<router-link :to="userProfileLink(notification.action.user)"> <router-link :to="userProfileLink(notification.action.user)">

View file

@ -11,7 +11,8 @@ const Notifications = {
const store = this.$store const store = this.$store
const credentials = store.state.users.currentUser.credentials const credentials = store.state.users.currentUser.credentials
notificationsFetcher.startFetching({ store, credentials }) const fetcherId = notificationsFetcher.startFetching({ store, credentials })
this.$store.commit('setNotificationFetcher', { fetcherId })
}, },
data () { data () {
return { return {

View file

@ -45,10 +45,6 @@
} }
} }
.notification-usercard {
margin: 0;
}
.non-mention { .non-mention {
display: flex; display: flex;
flex: 1; flex: 1;
@ -126,7 +122,7 @@
} }
.timeago { .timeago {
font-size: 12px; margin-right: .2em;
} }
.icon-retweet.lit { .icon-retweet.lit {

View file

@ -1,6 +1,7 @@
import statusPoster from '../../services/status_poster/status_poster.service.js' import statusPoster from '../../services/status_poster/status_poster.service.js'
import MediaUpload from '../media_upload/media_upload.vue' import MediaUpload from '../media_upload/media_upload.vue'
import ScopeSelector from '../scope_selector/scope_selector.vue' import ScopeSelector from '../scope_selector/scope_selector.vue'
import EmojiInput from '../emoji-input/emoji-input.vue'
import fileTypeService from '../../services/file_type/file_type.service.js' import fileTypeService from '../../services/file_type/file_type.service.js'
import Completion from '../../services/completion/completion.js' import Completion from '../../services/completion/completion.js'
import { take, filter, reject, map, uniqBy } from 'lodash' import { take, filter, reject, map, uniqBy } from 'lodash'
@ -30,7 +31,8 @@ const PostStatusForm = {
], ],
components: { components: {
MediaUpload, MediaUpload,
ScopeSelector ScopeSelector,
EmojiInput
}, },
mounted () { mounted () {
this.resize(this.$refs.textarea) this.resize(this.$refs.textarea)
@ -174,6 +176,9 @@ const PostStatusForm = {
}, },
formattingOptionsEnabled () { formattingOptionsEnabled () {
return this.$store.state.instance.formattingOptionsEnabled return this.$store.state.instance.formattingOptionsEnabled
},
postFormats () {
return this.$store.state.instance.postFormats || []
} }
}, },
methods: { methods: {
@ -222,6 +227,9 @@ const PostStatusForm = {
this.highlighted = 0 this.highlighted = 0
} }
}, },
onKeydown (e) {
e.stopPropagation()
},
setCaret ({target: {selectionStart}}) { setCaret ({target: {selectionStart}}) {
this.caret = selectionStart this.caret = selectionStart
}, },
@ -293,6 +301,8 @@ const PostStatusForm = {
}, },
paste (e) { paste (e) {
if (e.clipboardData.files.length > 0) { if (e.clipboardData.files.length > 0) {
// prevent pasting of file as text
e.preventDefault()
// Strangely, files property gets emptied after event propagation // Strangely, files property gets emptied after event propagation
// Trying to wrap it in array doesn't work. Plus I doubt it's possible // Trying to wrap it in array doesn't work. Plus I doubt it's possible
// to hold more than one file in clipboard. // to hold more than one file in clipboard.

View file

@ -10,16 +10,18 @@
<router-link :to="{ name: 'user-settings' }">{{ $t('post_status.account_not_locked_warning_link') }}</router-link> <router-link :to="{ name: 'user-settings' }">{{ $t('post_status.account_not_locked_warning_link') }}</router-link>
</i18n> </i18n>
<p v-if="this.newStatus.visibility == 'direct'" class="visibility-notice">{{ $t('post_status.direct_warning') }}</p> <p v-if="this.newStatus.visibility == 'direct'" class="visibility-notice">{{ $t('post_status.direct_warning') }}</p>
<input <EmojiInput
v-if="newStatus.spoilerText || alwaysShowSubject" v-if="newStatus.spoilerText || alwaysShowSubject"
type="text" type="text"
:placeholder="$t('post_status.content_warning')" :placeholder="$t('post_status.content_warning')"
v-model="newStatus.spoilerText" v-model="newStatus.spoilerText"
class="form-cw"> classname="form-control"
/>
<textarea <textarea
ref="textarea" ref="textarea"
@click="setCaret" @click="setCaret"
@keyup="setCaret" v-model="newStatus.status" :placeholder="$t('post_status.default')" rows="1" class="form-control" @keyup="setCaret" v-model="newStatus.status" :placeholder="$t('post_status.default')" rows="1" class="form-control"
@keydown="onKeydown"
@keydown.down="cycleForward" @keydown.down="cycleForward"
@keydown.up="cycleBackward" @keydown.up="cycleBackward"
@keydown.shift.tab="cycleBackward" @keydown.shift.tab="cycleBackward"
@ -30,15 +32,17 @@
@drop="fileDrop" @drop="fileDrop"
@dragover.prevent="fileDrag" @dragover.prevent="fileDrag"
@input="resize" @input="resize"
@paste="paste"> @paste="paste"
:disabled="posting"
>
</textarea> </textarea>
<div class="visibility-tray"> <div class="visibility-tray">
<span class="text-format" v-if="formattingOptionsEnabled"> <span class="text-format" v-if="formattingOptionsEnabled">
<label for="post-content-type" class="select"> <label for="post-content-type" class="select">
<select id="post-content-type" v-model="newStatus.contentType" class="form-control"> <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 v-for="postFormat in postFormats" :key="postFormat" :value="postFormat">
<option value="text/html">HTML</option> {{$t(`post_status.content_type["${postFormat}"]`)}}
<option value="text/markdown">Markdown</option> </option>
</select> </select>
<i class="icon-down-open"></i> <i class="icon-down-open"></i>
</label> </label>
@ -52,14 +56,18 @@
:onScopeChange="changeVis"/> :onScopeChange="changeVis"/>
</div> </div>
</div> </div>
<div style="position:relative;" v-if="candidates"> <div class="autocomplete-panel" v-if="candidates">
<div class="autocomplete-panel"> <div class="autocomplete-panel-body">
<div v-for="candidate in candidates" @click="replace(candidate.utf || (candidate.screen_name + ' '))"> <div
<div class="autocomplete" :class="{ highlighted: candidate.highlighted }"> v-for="(candidate, index) in candidates"
<span v-if="candidate.img"><img :src="candidate.img"></img></span> :key="index"
<span v-else>{{candidate.utf}}</span> @click="replace(candidate.utf || (candidate.screen_name + ' '))"
<span>{{candidate.screen_name}}<small>{{candidate.name}}</small></span> class="autocomplete-item"
</div> :class="{ highlighted: candidate.highlighted }"
>
<span v-if="candidate.img"><img :src="candidate.img" /></span>
<span v-else>{{candidate.utf}}</span>
<span>{{candidate.screen_name}}<small>{{candidate.name}}</small></span>
</div> </div>
</div> </div>
</div> </div>
@ -81,10 +89,10 @@
<div class="media-upload-wrapper" v-for="file in newStatus.files"> <div class="media-upload-wrapper" v-for="file in newStatus.files">
<i class="fa button-icon icon-cancel" @click="removeMediaFile(file)"></i> <i class="fa button-icon icon-cancel" @click="removeMediaFile(file)"></i>
<div class="media-upload-container attachment"> <div class="media-upload-container attachment">
<img class="thumbnail media-upload" :src="file.image" v-if="type(file) === 'image'"></img> <img class="thumbnail media-upload" :src="file.url" v-if="type(file) === 'image'"></img>
<video v-if="type(file) === 'video'" :src="file.image" controls></video> <video v-if="type(file) === 'video'" :src="file.url" controls></video>
<audio v-if="type(file) === 'audio'" :src="file.image" controls></audio> <audio v-if="type(file) === 'audio'" :src="file.url" controls></audio>
<a v-if="type(file) === 'unknown'" :href="file.image">{{file.url}}</a> <a v-if="type(file) === 'unknown'" :href="file.url">{{file.url}}</a>
</div> </div>
</div> </div>
</div> </div>
@ -258,52 +266,5 @@
cursor: pointer; cursor: pointer;
z-index: 4; 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> </style>

View file

@ -35,6 +35,9 @@ const registration = {
}, },
computed: { computed: {
token () { return this.$route.params.token }, token () { return this.$route.params.token },
bioPlaceholder () {
return this.$t('registration.bio_placeholder').replace(/\s*\n\s*/g, ' \n')
},
...mapState({ ...mapState({
registrationOpen: (state) => state.instance.registrationOpen, registrationOpen: (state) => state.instance.registrationOpen,
signedIn: (state) => !!state.users.currentUser, signedIn: (state) => !!state.users.currentUser,

View file

@ -45,7 +45,7 @@
<div class='form-group'> <div class='form-group'>
<label class='form--label' for='bio'>{{$t('registration.bio')}} ({{$t('general.optional')}})</label> <label class='form--label' for='bio'>{{$t('registration.bio')}} ({{$t('general.optional')}})</label>
<textarea :disabled="isPending" v-model='user.bio' class='form-control' id='bio' :placeholder="$t('registration.bio_placeholder')"></textarea> <textarea :disabled="isPending" v-model='user.bio' class='form-control' id='bio' :placeholder="bioPlaceholder"></textarea>
</div> </div>
<div class='form-group' :class="{ 'form-group--error': $v.user.password.$error }"> <div class='form-group' :class="{ 'form-group--error': $v.user.password.$error }">

View file

@ -0,0 +1,10 @@
export default {
props: [ 'user' ],
computed: {
subscribeUrl () {
// eslint-disable-next-line no-undef
const serverUrl = new URL(this.user.statusnet_profile_url)
return `${serverUrl.protocol}//${serverUrl.host}/main/ostatus`
}
}
}

View file

@ -0,0 +1,24 @@
<template>
<div class="remote-follow">
<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>
</template>
<script src="./remote_follow.js"></script>
<style lang="scss">
.remote-follow {
max-width: 220px;
.remote-button {
width: 100%;
min-height: 28px;
}
}
</style>

View file

@ -1,8 +1,13 @@
/* eslint-env browser */ /* eslint-env browser */
import { filter, trim } from 'lodash'
import TabSwitcher from '../tab_switcher/tab_switcher.js' import TabSwitcher from '../tab_switcher/tab_switcher.js'
import StyleSwitcher from '../style_switcher/style_switcher.vue' import StyleSwitcher from '../style_switcher/style_switcher.vue'
import InterfaceLanguageSwitcher from '../interface_language_switcher/interface_language_switcher.vue' import InterfaceLanguageSwitcher from '../interface_language_switcher/interface_language_switcher.vue'
import { filter, trim } from 'lodash' import { extractCommit } from '../../services/version/version.service'
const pleromaFeCommitUrl = 'https://git.pleroma.social/pleroma/pleroma-fe/commit/'
const pleromaBeCommitUrl = 'https://git.pleroma.social/pleroma/pleroma/commit/'
const settings = { const settings = {
data () { data () {
@ -42,6 +47,11 @@ const settings = {
pauseOnUnfocusedLocal: user.pauseOnUnfocused, pauseOnUnfocusedLocal: user.pauseOnUnfocused,
hoverPreviewLocal: user.hoverPreview, hoverPreviewLocal: user.hoverPreview,
hideMutedPostsLocal: typeof user.hideMutedPosts === 'undefined'
? instance.hideMutedPosts
: user.hideMutedPosts,
hideMutedPostsDefault: this.$t('settings.values.' + instance.hideMutedPosts),
collapseMessageWithSubjectLocal: typeof user.collapseMessageWithSubject === 'undefined' collapseMessageWithSubjectLocal: typeof user.collapseMessageWithSubject === 'undefined'
? instance.collapseMessageWithSubject ? instance.collapseMessageWithSubject
: user.collapseMessageWithSubject, : user.collapseMessageWithSubject,
@ -83,7 +93,10 @@ const settings = {
// Future spec, still not supported in Nightly 63 as of 08/2018 // Future spec, still not supported in Nightly 63 as of 08/2018
Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'audioTracks'), Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'audioTracks'),
playVideosInModal: user.playVideosInModal, playVideosInModal: user.playVideosInModal,
useContainFit: user.useContainFit useContainFit: user.useContainFit,
backendVersion: instance.backendVersion,
frontendVersion: instance.frontendVersion
} }
}, },
components: { components: {
@ -98,7 +111,16 @@ const settings = {
currentSaveStateNotice () { currentSaveStateNotice () {
return this.$store.state.interface.settings.currentSaveStateNotice return this.$store.state.interface.settings.currentSaveStateNotice
}, },
instanceSpecificPanelPresent () { return this.$store.state.instance.showInstanceSpecificPanel } postFormats () {
return this.$store.state.instance.postFormats || []
},
instanceSpecificPanelPresent () { return this.$store.state.instance.showInstanceSpecificPanel },
frontendVersionLink () {
return pleromaFeCommitUrl + this.frontendVersion
},
backendVersionLink () {
return pleromaBeCommitUrl + extractCommit(this.backendVersion)
}
}, },
watch: { watch: {
hideAttachmentsLocal (value) { hideAttachmentsLocal (value) {
@ -165,6 +187,9 @@ const settings = {
value = filter(value.split('\n'), (word) => trim(word).length > 0) value = filter(value.split('\n'), (word) => trim(word).length > 0)
this.$store.dispatch('setOption', { name: 'muteWords', value }) this.$store.dispatch('setOption', { name: 'muteWords', value })
}, },
hideMutedPostsLocal (value) {
this.$store.dispatch('setOption', { name: 'hideMutedPosts', value })
},
collapseMessageWithSubjectLocal (value) { collapseMessageWithSubjectLocal (value) {
this.$store.dispatch('setOption', { name: 'collapseMessageWithSubject', value }) this.$store.dispatch('setOption', { name: 'collapseMessageWithSubject', value })
}, },

View file

@ -36,6 +36,10 @@
<div class="setting-item"> <div class="setting-item">
<h2>{{$t('nav.timeline')}}</h2> <h2>{{$t('nav.timeline')}}</h2>
<ul class="setting-list"> <ul class="setting-list">
<li>
<input type="checkbox" id="hideMutedPosts" v-model="hideMutedPostsLocal">
<label for="hideMutedPosts">{{$t('settings.hide_muted_posts')}} {{$t('settings.instance_default', { value: hideMutedPostsDefault })}}</label>
</li>
<li> <li>
<input type="checkbox" id="collapseMessageWithSubject" v-model="collapseMessageWithSubjectLocal"> <input type="checkbox" id="collapseMessageWithSubject" v-model="collapseMessageWithSubjectLocal">
<label for="collapseMessageWithSubject"> <label for="collapseMessageWithSubject">
@ -105,17 +109,9 @@
{{$t('settings.post_status_content_type')}} {{$t('settings.post_status_content_type')}}
<label for="postContentType" class="select"> <label for="postContentType" class="select">
<select id="postContentType" v-model="postContentTypeLocal"> <select id="postContentType" v-model="postContentTypeLocal">
<option value="text/plain"> <option v-for="postFormat in postFormats" :key="postFormat" :value="postFormat">
{{$t('settings.status_content_type_plain')}} {{$t(`post_status.content_type["${postFormat}"]`)}}
{{postContentTypeDefault == 'text/plain' ? $t('settings.instance_default_simple') : ''}} {{postContentTypeDefault === postFormat ? $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> </option>
</select> </select>
<i class="icon-down-open"/> <i class="icon-down-open"/>
@ -275,6 +271,28 @@
</div> </div>
</div> </div>
</div> </div>
<div :label="$t('settings.version.title')" >
<div class="setting-item">
<ul class="setting-list">
<li>
<p>{{$t('settings.version.backend_version')}}</p>
<ul class="option-list">
<li>
<a :href="backendVersionLink" target="_blank">{{backendVersion}}</a>
</li>
</ul>
</li>
<li>
<p>{{$t('settings.version.frontend_version')}}</p>
<ul class="option-list">
<li>
<a :href="frontendVersionLink" target="_blank">{{frontendVersion}}</a>
</li>
</ul>
</li>
</ul>
</div>
</div>
</tab-switcher> </tab-switcher>
</keep-alive> </keep-alive>
</div> </div>

View file

@ -1,18 +1,17 @@
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' import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils'
import GestureService from '../../services/gesture_service/gesture_service'
// 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 = { const SideDrawer = {
props: [ 'logout' ], props: [ 'logout' ],
data: () => ({ data: () => ({
closed: true, closed: true,
touchCoord: [0, 0] closeGesture: undefined
}), }),
components: { UserCardContent }, created () {
this.closeGesture = GestureService.swipeGesture(GestureService.DIRECTION_LEFT, this.toggleDrawer)
},
components: { UserCard },
computed: { computed: {
currentUser () { currentUser () {
return this.$store.state.users.currentUser return this.$store.state.users.currentUser
@ -46,13 +45,10 @@ const SideDrawer = {
this.toggleDrawer() this.toggleDrawer()
}, },
touchStart (e) { touchStart (e) {
this.touchCoord = touchEventCoord(e) GestureService.beginSwipe(e, this.closeGesture)
}, },
touchMove (e) { touchMove (e) {
const delta = deltaCoord(this.touchCoord, touchEventCoord(e)) GestureService.updateSwipe(e, this.closeGesture)
if (delta[0] < -30 && Math.abs(delta[1]) < Math.abs(delta[0]) && !this.closed) {
this.toggleDrawer()
}
} }
} }
} }

View file

@ -2,25 +2,21 @@
<div class="side-drawer-container" <div class="side-drawer-container"
:class="{ 'side-drawer-container-closed': closed, 'side-drawer-container-open': !closed }" :class="{ 'side-drawer-container-closed': closed, 'side-drawer-container-open': !closed }"
> >
<div class="side-drawer-darken" :class="{ 'side-drawer-darken-closed': closed}" />
<div class="side-drawer" <div class="side-drawer"
:class="{'side-drawer-closed': closed}" :class="{'side-drawer-closed': closed}"
@touchstart="touchStart" @touchstart="touchStart"
@touchmove="touchMove" @touchmove="touchMove"
> >
<div class="side-drawer-heading" @click="toggleDrawer"> <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> <div class="side-drawer-logo-wrapper" v-else>
<img :src="logo"/> <img :src="logo"/>
<span>{{sitename}}</span> <span>{{sitename}}</span>
</div> </div>
</div> </div>
<ul> <ul>
<li v-if="currentUser" @click="toggleDrawer"> <li v-if="!currentUser" @click="toggleDrawer">
<router-link :to="{ name: 'new-status', params: { username: currentUser.screen_name } }">
{{ $t("post_status.new_status") }}
</router-link>
</li>
<li v-else @click="toggleDrawer">
<router-link :to="{ name: 'login' }"> <router-link :to="{ name: 'login' }">
{{ $t("login.login") }} {{ $t("login.login") }}
</router-link> </router-link>
@ -116,17 +112,33 @@
height: 100%; height: 100%;
display: flex; display: flex;
align-items: stretch; align-items: stretch;
transition-duration: 0s;
transition-property: transform;
} }
.side-drawer-container-open { .side-drawer-container-open {
transition-delay: 0.0s; transform: translate(0%);
transition-property: left;
} }
.side-drawer-container-closed { .side-drawer-container-closed {
left: -100%; transition-delay: 0.35s;
transition-delay: 0.5s; transform: translate(-100%);
transition-property: left; }
.side-drawer-darken {
top: 0;
left: 0;
width: 100vw;
height: 100vh;
position: fixed;
z-index: -1;
transition: 0.35s;
transition-property: background-color;
background-color: rgba(0, 0, 0, 0.5);
}
.side-drawer-darken-closed {
background-color: rgba(0, 0, 0, 0);
} }
.side-drawer-click-outside { .side-drawer-click-outside {
@ -135,8 +147,9 @@
.side-drawer { .side-drawer {
overflow-x: hidden; overflow-x: hidden;
transition: 0.35s;
transition-timing-function: cubic-bezier(0, 1, 0.5, 1); transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
transition: 0.35s;
transition-property: transform;
margin: 0 0 0 -100px; margin: 0 0 0 -100px;
padding: 0 0 1em 100px; padding: 0 0 1em 100px;
width: 80%; width: 80%;
@ -181,15 +194,6 @@
display: flex; display: flex;
padding: 0; padding: 0;
margin: 0; margin: 0;
.profile-panel-background {
border-radius: 0;
.panel-heading {
background: transparent;
flex-direction: column;
align-items: stretch;
}
}
} }
.side-drawer ul { .side-drawer ul {

View file

@ -3,7 +3,7 @@ import FavoriteButton from '../favorite_button/favorite_button.vue'
import RetweetButton from '../retweet_button/retweet_button.vue' import RetweetButton from '../retweet_button/retweet_button.vue'
import DeleteButton from '../delete_button/delete_button.vue' import DeleteButton from '../delete_button/delete_button.vue'
import PostStatusForm from '../post_status_form/post_status_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'
import UserAvatar from '../user_avatar/user_avatar.vue' import UserAvatar from '../user_avatar/user_avatar.vue'
import Gallery from '../gallery/gallery.vue' import Gallery from '../gallery/gallery.vue'
import LinkPreview from '../link-preview/link-preview.vue' import LinkPreview from '../link-preview/link-preview.vue'
@ -145,11 +145,11 @@ const Status = {
return !!(this.status.in_reply_to_status_id && this.status.in_reply_to_user_id) return !!(this.status.in_reply_to_status_id && this.status.in_reply_to_user_id)
}, },
replyToName () { replyToName () {
const user = this.$store.state.users.usersObject[this.status.in_reply_to_user_id] if (this.status.in_reply_to_screen_name) {
if (user) {
return user.screen_name
} else {
return this.status.in_reply_to_screen_name return this.status.in_reply_to_screen_name
} else {
const user = this.$store.getters.findUser(this.status.in_reply_to_user_id)
return user && user.screen_name
} }
}, },
hideReply () { hideReply () {
@ -259,7 +259,7 @@ const Status = {
RetweetButton, RetweetButton,
DeleteButton, DeleteButton,
PostStatusForm, PostStatusForm,
UserCardContent, UserCard,
UserAvatar, UserAvatar,
Gallery, Gallery,
LinkPreview LinkPreview
@ -310,7 +310,6 @@ const Status = {
this.replying = !this.replying this.replying = !this.replying
}, },
gotoOriginal (id) { gotoOriginal (id) {
// only handled by conversation, not status_or_conversation
if (this.inConversation) { if (this.inConversation) {
this.$emit('goto', id) this.$emit('goto', id)
} }

View file

@ -12,7 +12,7 @@
</div> </div>
</template> </template>
<template v-else> <template v-else>
<div v-if="retweet && !noHeading" :class="[repeaterClass, { highlighted: repeaterStyle }]" :style="[repeaterStyle]" class="media container retweet-info"> <div v-if="retweet && !noHeading && !inConversation" :class="[repeaterClass, { highlighted: repeaterStyle }]" :style="[repeaterStyle]" class="media container retweet-info">
<UserAvatar class="media-left" v-if="retweet" :betterShadow="betterShadow" :src="statusoid.user.profile_image_url_original"/> <UserAvatar class="media-left" v-if="retweet" :betterShadow="betterShadow" :src="statusoid.user.profile_image_url_original"/>
<div class="media-body faint"> <div class="media-body faint">
<span class="user-name"> <span class="user-name">
@ -24,16 +24,14 @@
</div> </div>
</div> </div>
<div :class="[userClass, { highlighted: userStyle, 'is-retweet': retweet }]" :style="[ userStyle ]" class="media status"> <div :class="[userClass, { highlighted: userStyle, 'is-retweet': retweet && !inConversation }]" :style="[ userStyle ]" class="media status">
<div v-if="!noHeading" class="media-left"> <div v-if="!noHeading" class="media-left">
<router-link :to="userProfileLink" @click.stop.prevent.capture.native="toggleUserExpanded"> <router-link :to="userProfileLink" @click.stop.prevent.capture.native="toggleUserExpanded">
<UserAvatar :compact="compact" :betterShadow="betterShadow" :src="status.user.profile_image_url_original"/> <UserAvatar :compact="compact" :betterShadow="betterShadow" :src="status.user.profile_image_url_original"/>
</router-link> </router-link>
</div> </div>
<div class="status-body"> <div class="status-body">
<div class="usercard" v-if="userExpanded"> <UserCard :user="status.user" :rounded="true" :bordered="true" class="status-usercard" v-if="userExpanded"/>
<user-card-content :user="status.user" :switcher="false"></user-card-content>
</div>
<div v-if="!noHeading" class="media-heading"> <div v-if="!noHeading" class="media-heading">
<div class="heading-name-row"> <div class="heading-name-row">
<div class="name-and-account-name"> <div class="name-and-account-name">
@ -77,13 +75,13 @@
<router-link :to="replyProfileLink"> <router-link :to="replyProfileLink">
{{replyToName}} {{replyToName}}
</router-link> </router-link>
<span class="faint replies-separator" v-if="replies.length"> <span class="faint replies-separator" v-if="replies && replies.length">
- -
</span> </span>
</div> </div>
<div class="replies" v-if="inConversation && !isPreview"> <div class="replies" v-if="inConversation && !isPreview">
<span class="faint" v-if="replies.length">{{$t('status.replies_list')}}</span> <span class="faint" v-if="replies && replies.length">{{$t('status.replies_list')}}</span>
<span class="reply-link faint" v-for="reply in replies"> <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> <a href="#" @click.prevent="gotoOriginal(reply.id)" @mouseenter="replyEnter(reply.id, $event)" @mouseout="replyLeave()">{{reply.name}}</a>
</span> </span>
</div> </div>
@ -137,9 +135,8 @@
<div v-if="!noHeading && !isPreview" class='status-actions media-body'> <div v-if="!noHeading && !isPreview" class='status-actions media-body'>
<div v-if="loggedIn"> <div v-if="loggedIn">
<a href="#" v-on:click.prevent="toggleReplying" :title="$t('tool_tip.reply')"> <i class="button-icon icon-reply" v-on:click.prevent="toggleReplying" :title="$t('tool_tip.reply')" :class="{'icon-reply-active': replying}"></i>
<i class="button-icon icon-reply" :class="{'icon-reply-active': replying}"></i> <span v-if="status.replies_count > 0">{{status.replies_count}}</span>
</a>
</div> </div>
<retweet-button :visibility='status.visibility' :loggedIn='loggedIn' :status='status'></retweet-button> <retweet-button :visibility='status.visibility' :loggedIn='loggedIn' :status='status'></retweet-button>
<favorite-button :loggedIn='loggedIn' :status='status'></favorite-button> <favorite-button :loggedIn='loggedIn' :status='status'></favorite-button>
@ -248,8 +245,7 @@ $status-margin: 0.75em;
padding: 0; padding: 0;
} }
.usercard { .status-usercard {
margin: 0;
margin-bottom: $status-margin; margin-bottom: $status-margin;
} }
@ -422,6 +418,11 @@ $status-margin: 0.75em;
max-height: 400px; max-height: 400px;
vertical-align: middle; vertical-align: middle;
object-fit: contain; object-fit: contain;
&.emoji {
width: 32px;
height: 32px;
}
} }
blockquote { blockquote {
@ -549,6 +550,7 @@ $status-margin: 0.75em;
.icon-reply:hover { .icon-reply:hover {
color: $fallback--cBlue; color: $fallback--cBlue;
color: var(--cBlue, $fallback--cBlue); color: var(--cBlue, $fallback--cBlue);
cursor: pointer;
} }
.icon-reply.icon-reply-active { .icon-reply.icon-reply-active {

View file

@ -1,22 +0,0 @@
import Status from '../status/status.vue'
import Conversation from '../conversation/conversation.vue'
const statusOrConversation = {
props: ['statusoid'],
data () {
return {
expanded: false
}
},
components: {
Status,
Conversation
},
methods: {
toggleExpanded () {
this.expanded = !this.expanded
}
}
}
export default statusOrConversation

View file

@ -1,14 +0,0 @@
<template>
<div>
<conversation v-if="expanded" @toggleExpanded="toggleExpanded" :collapsable="true" :statusoid="statusoid"></conversation>
<status v-if="!expanded" @toggleExpanded="toggleExpanded" :expandable="true" :inConversation="false" :focused="false" :statusoid="statusoid"></status>
</div>
</template>
<script src="./status_or_conversation.js"></script>
<style lang="scss">
.spacer {
height: 1em
}
</style>

View file

@ -1,6 +1,6 @@
import Status from '../status/status.vue' import Status from '../status/status.vue'
import timelineFetcher from '../../services/timeline_fetcher/timeline_fetcher.service.js' import timelineFetcher from '../../services/timeline_fetcher/timeline_fetcher.service.js'
import StatusOrConversation from '../status_or_conversation/status_or_conversation.vue' import Conversation from '../conversation/conversation.vue'
import { throttle } from 'lodash' import { throttle } from 'lodash'
const Timeline = { const Timeline = {
@ -43,7 +43,7 @@ const Timeline = {
}, },
components: { components: {
Status, Status,
StatusOrConversation Conversation
}, },
created () { created () {
const store = this.$store const store = this.$store
@ -132,7 +132,9 @@ const Timeline = {
} }
if (count > 0) { if (count > 0) {
// only 'stream' them when you're scrolled to the top // 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.paused &&
!(this.unfocused && this.$store.state.config.pauseOnUnfocused) !(this.unfocused && this.$store.state.config.pauseOnUnfocused)
) { ) {

View file

@ -16,7 +16,13 @@
</div> </div>
<div :class="classes.body"> <div :class="classes.body">
<div class="timeline"> <div class="timeline">
<status-or-conversation v-for="status in timeline.visibleStatuses" :key="status.id" v-bind:statusoid="status" class="status-fadein"></status-or-conversation> <conversation
v-for="status in timeline.visibleStatuses"
class="status-fadein"
:key="status.id"
:statusoid="status"
:collapsable="true"
/>
</div> </div>
</div> </div>
<div :class="classes.footer"> <div :class="classes.footer">

View file

@ -23,6 +23,11 @@ const UserAvatar = {
imageLoadError () { imageLoadError () {
this.showPlaceholder = true this.showPlaceholder = true
} }
},
watch: {
src () {
this.showPlaceholder = false
}
} }
} }

View file

@ -1,10 +1,11 @@
import UserAvatar from '../user_avatar/user_avatar.vue' import UserAvatar from '../user_avatar/user_avatar.vue'
import RemoteFollow from '../remote_follow/remote_follow.vue'
import { hex2rgb } from '../../services/color_convert/color_convert.js' import { hex2rgb } from '../../services/color_convert/color_convert.js'
import { requestFollow, requestUnfollow } from '../../services/follow_manipulate/follow_manipulate' import { requestFollow, requestUnfollow } from '../../services/follow_manipulate/follow_manipulate'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
export default { export default {
props: [ 'user', 'switcher', 'selected', 'hideBio' ], props: [ 'user', 'switcher', 'selected', 'hideBio', 'rounded', 'bordered' ],
data () { data () {
return { return {
followRequestInProgress: false, followRequestInProgress: false,
@ -15,8 +16,18 @@ export default {
betterShadow: this.$store.state.interface.browserSupport.cssFilter betterShadow: this.$store.state.interface.browserSupport.cssFilter
} }
}, },
created () {
this.$store.dispatch('fetchUserRelationship', this.user.id)
},
computed: { 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 const color = this.$store.state.config.customTheme.colors
? this.$store.state.config.customTheme.colors.bg // v2 ? this.$store.state.config.customTheme.colors.bg // v2
: this.$store.state.config.colors.bg // v1 : this.$store.state.config.colors.bg // v1
@ -89,36 +100,37 @@ export default {
} }
}, },
components: { components: {
UserAvatar UserAvatar,
RemoteFollow
}, },
methods: { methods: {
followUser () { followUser () {
const store = this.$store
this.followRequestInProgress = true this.followRequestInProgress = true
requestFollow(this.user, this.$store).then(({sent}) => { requestFollow(this.user, store).then(({sent}) => {
this.followRequestInProgress = false this.followRequestInProgress = false
this.followRequestSent = sent this.followRequestSent = sent
}) })
}, },
unfollowUser () { unfollowUser () {
const store = this.$store
this.followRequestInProgress = true this.followRequestInProgress = true
requestUnfollow(this.user, this.$store).then(() => { requestUnfollow(this.user, store).then(() => {
this.followRequestInProgress = false this.followRequestInProgress = false
store.commit('removeStatus', { timeline: 'friends', userId: this.user.id })
}) })
}, },
blockUser () { blockUser () {
const store = this.$store this.$store.dispatch('blockUser', this.user.id)
store.state.api.backendInteractor.blockUser(this.user.id)
.then((blockedUser) => store.commit('addNewUsers', [blockedUser]))
}, },
unblockUser () { unblockUser () {
const store = this.$store this.$store.dispatch('unblockUser', this.user.id)
store.state.api.backendInteractor.unblockUser(this.user.id)
.then((unblockedUser) => store.commit('addNewUsers', [unblockedUser]))
}, },
toggleMute () { muteUser () {
const store = this.$store this.$store.dispatch('muteUser', this.user.id)
store.commit('setMuted', {user: this.user, muted: !this.user.muted}) },
store.state.api.backendInteractor.setUserMute(this.user) unmuteUser () {
this.$store.dispatch('unmuteUser', this.user.id)
}, },
setProfileView (v) { setProfileView (v) {
if (this.switcher) { if (this.switcher) {

View file

@ -1,6 +1,6 @@
<template> <template>
<div id="heading" class="profile-panel-background" :style="headingStyle"> <div class="user-card" :class="classes" :style="style">
<div class="panel-heading text-center"> <div class="panel-heading">
<div class='user-info'> <div class='user-info'>
<div class='container'> <div class='container'>
<router-link :to="userProfileLink(user)"> <router-link :to="userProfileLink(user)">
@ -11,7 +11,7 @@
<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-if="user.name_html" v-html="user.name_html"></div>
<div :title="user.name" class='user-name' v-else>{{user.name}}</div> <div :title="user.name" class='user-name' v-else>{{user.name}}</div>
<router-link :to="{ name: 'user-settings' }" v-if="!isOtherUser"> <router-link :to="{ name: 'user-settings' }" v-if="!isOtherUser">
<i class="button-icon icon-cog usersettings" :title="$t('tool_tip.user_settings')"></i> <i class="button-icon icon-pencil usersettings" :title="$t('tool_tip.user_settings')"></i>
</router-link> </router-link>
<a :href="user.statusnet_profile_url" target="_blank" v-if="isOtherUser && !user.is_local"> <a :href="user.statusnet_profile_url" target="_blank" v-if="isOtherUser && !user.is_local">
<i class="icon-link-ext usersettings"></i> <i class="icon-link-ext usersettings"></i>
@ -74,24 +74,18 @@
</div> </div>
<div class='mute' v-if='isOtherUser && loggedIn'> <div class='mute' v-if='isOtherUser && loggedIn'>
<span v-if='user.muted'> <span v-if='user.muted'>
<button @click="toggleMute" class="pressed"> <button @click="unmuteUser" class="pressed">
{{ $t('user_card.muted') }} {{ $t('user_card.muted') }}
</button> </button>
</span> </span>
<span v-if='!user.muted'> <span v-if='!user.muted'>
<button @click="toggleMute"> <button @click="muteUser">
{{ $t('user_card.mute') }} {{ $t('user_card.mute') }}
</button> </button>
</span> </span>
</div> </div>
<div class="remote-follow" v-if='!loggedIn && user.is_local'> <div v-if='!loggedIn && user.is_local'>
<form method="POST" :action='subscribeUrl'> <RemoteFollow :user="user" />
<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>
<div class='block' v-if='isOtherUser && loggedIn'> <div class='block' v-if='isOtherUser && loggedIn'>
<span v-if='user.statusnet_blocking'> <span v-if='user.statusnet_blocking'>
@ -108,7 +102,7 @@
</div> </div>
</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 v-if="!hideUserStatsLocal && switcher" class="user-counts">
<div class="user-count" v-on:click.prevent="setProfileView('statuses')"> <div class="user-count" v-on:click.prevent="setProfileView('statuses')">
<h5>{{ $t('user_card.statuses') }}</h5> <h5>{{ $t('user_card.statuses') }}</h5>
@ -123,40 +117,75 @@
<span>{{user.followers_count}}</span> <span>{{user.followers_count}}</span>
</div> </div>
</div> </div>
<p @click.prevent="linkClicked" v-if="!hideBio && user.description_html" class="profile-bio" v-html="user.description_html"></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="profile-bio">{{ user.description }}</p> <p v-else-if="!hideBio" class="user-card-bio">{{ user.description }}</p>
</div> </div>
</div> </div>
</template> </template>
<script src="./user_card_content.js"></script> <script src="./user_card.js"></script>
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import '../../_variables.scss';
.profile-panel-background { .user-card {
background-size: cover; background-size: cover;
border-radius: $fallback--panelRadius;
border-radius: var(--panelRadius, $fallback--panelRadius);
overflow: hidden; overflow: hidden;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
.panel-heading { .panel-heading {
padding: .5em 0; padding: .5em 0;
text-align: center; text-align: center;
box-shadow: none; box-shadow: none;
background: transparent;
flex-direction: column;
align-items: stretch;
} }
}
.profile-panel-body { .panel-body {
word-wrap: break-word; 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), $fallback--bg 80%);
background: linear-gradient(to bottom, rgba(0, 0, 0, 0), var(--bg, $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; text-align: center;
img {
object-fit: contain;
vertical-align: middle;
max-width: 100%;
max-height: 400px;
.emoji {
width: 32px;
height: 32px;
}
}
}
// 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);
} }
} }
@ -340,11 +369,6 @@
min-height: 28px; min-height: 28px;
} }
.remote-follow {
max-width: 220px;
min-height: 28px;
}
.follow { .follow {
max-width: 220px; max-width: 220px;
min-height: 28px; min-height: 28px;
@ -393,25 +417,4 @@
text-decoration: none; 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> </style>

View file

@ -1,6 +1,6 @@
import LoginForm from '../login_form/login_form.vue' import LoginForm from '../login_form/login_form.vue'
import PostStatusForm from '../post_status_form/post_status_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 = { const UserPanel = {
computed: { computed: {
@ -9,7 +9,7 @@ const UserPanel = {
components: { components: {
LoginForm, LoginForm,
PostStatusForm, PostStatusForm,
UserCardContent UserCard
} }
} }

View file

@ -1,7 +1,7 @@
<template> <template>
<div class="user-panel"> <div class="user-panel">
<div v-if='user' class="panel panel-default" style="overflow: visible;"> <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"> <div class="panel-footer">
<post-status-form v-if='user'></post-status-form> <post-status-form v-if='user'></post-status-form>
</div> </div>
@ -11,13 +11,3 @@
</template> </template>
<script src="./user_panel.js"></script> <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>

View file

@ -1,6 +1,6 @@
import { compose } from 'vue-compose' import { compose } from 'vue-compose'
import get from 'lodash/get' 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 FollowCard from '../follow_card/follow_card.vue'
import Timeline from '../timeline/timeline.vue' import Timeline from '../timeline/timeline.vue'
import withLoadMore from '../../hocs/with_load_more/with_load_more' import withLoadMore from '../../hocs/with_load_more/with_load_more'
@ -9,7 +9,7 @@ import withList from '../../hocs/with_list/with_list'
const FollowerList = compose( const FollowerList = compose(
withLoadMore({ withLoadMore({
fetch: (props, $store) => $store.dispatch('addFollowers', props.userId), fetch: (props, $store) => $store.dispatch('addFollowers', props.userId),
select: (props, $store) => get($store.getters.userById(props.userId), 'followers', []), select: (props, $store) => get($store.getters.findUser(props.userId), 'followers', []),
destory: (props, $store) => $store.dispatch('clearFollowers', props.userId), destory: (props, $store) => $store.dispatch('clearFollowers', props.userId),
childPropName: 'entries', childPropName: 'entries',
additionalPropNames: ['userId'] additionalPropNames: ['userId']
@ -20,7 +20,7 @@ const FollowerList = compose(
const FriendList = compose( const FriendList = compose(
withLoadMore({ withLoadMore({
fetch: (props, $store) => $store.dispatch('addFriends', props.userId), fetch: (props, $store) => $store.dispatch('addFriends', props.userId),
select: (props, $store) => get($store.getters.userById(props.userId), 'friends', []), select: (props, $store) => get($store.getters.findUser(props.userId), 'friends', []),
destory: (props, $store) => $store.dispatch('clearFriends', props.userId), destory: (props, $store) => $store.dispatch('clearFriends', props.userId),
childPropName: 'entries', childPropName: 'entries',
additionalPropNames: ['userId'] additionalPropNames: ['userId']
@ -31,28 +31,16 @@ const FriendList = compose(
const UserProfile = { const UserProfile = {
data () { data () {
return { return {
error: false error: false,
fetchedUserId: null
} }
}, },
created () { 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) { if (!this.user.id) {
this.$store.dispatch('fetchUser', this.fetchBy) this.fetchUserId()
.catch((reason) => { .then(() => this.startUp())
const errorMessage = get(reason, 'error.error') } else {
if (errorMessage === 'No user with such user_id') { // Known error this.startUp()
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 () { destroyed () {
@ -69,7 +57,7 @@ const UserProfile = {
return this.$store.state.statuses.timelines.media return this.$store.state.statuses.timelines.media
}, },
userId () { userId () {
return this.$route.params.id || this.user.id return this.$route.params.id || this.user.id || this.fetchedUserId
}, },
userName () { userName () {
return this.$route.params.name || this.user.screen_name return this.$route.params.name || this.user.screen_name
@ -79,10 +67,9 @@ const UserProfile = {
this.userId === this.$store.state.users.currentUser.id this.userId === this.$store.state.users.currentUser.id
}, },
userInStore () { userInStore () {
if (this.isExternal) { const routeParams = this.$route.params
return this.$store.getters.userById(this.userId) // This needs fetchedUserId so that computed will be refreshed when user is fetched
} return this.$store.getters.findUser(this.fetchedUserId || routeParams.name || routeParams.id)
return this.$store.getters.userByName(this.userName)
}, },
user () { user () {
if (this.timeline.statuses[0]) { if (this.timeline.statuses[0]) {
@ -93,9 +80,6 @@ const UserProfile = {
} }
return {} return {}
}, },
fetchBy () {
return this.isExternal ? this.userId : this.userName
},
isExternal () { isExternal () {
return this.$route.name === 'external-user-profile' return this.$route.name === 'external-user-profile'
}, },
@ -109,14 +93,38 @@ const UserProfile = {
methods: { methods: {
startFetchFavorites () { startFetchFavorites () {
if (this.isUs) { if (this.isUs) {
this.$store.dispatch('startFetching', { timeline: 'favorites', userId: this.fetchBy }) this.$store.dispatch('startFetching', { timeline: 'favorites', userId: this.userId })
} }
}, },
fetchUserId () {
let fetchPromise
if (this.userId && !this.$route.params.name) {
fetchPromise = this.$store.dispatch('fetchUser', this.userId)
} else {
fetchPromise = this.$store.dispatch('fetchUser', this.userName)
.then(({ id }) => {
this.fetchedUserId = id
})
}
return fetchPromise
.catch((reason) => {
const errorMessage = get(reason, 'error.error')
if (errorMessage === 'No user with such user_id') { // Known error
this.error = this.$t('user_profile.profile_does_not_exist')
} else if (errorMessage) {
this.error = errorMessage
} else {
this.error = this.$t('user_profile.profile_loading_error')
}
})
.then(() => this.startUp())
},
startUp () { startUp () {
this.$store.dispatch('startFetching', { timeline: 'user', userId: this.fetchBy }) if (this.userId) {
this.$store.dispatch('startFetching', { timeline: 'media', userId: this.fetchBy }) this.$store.dispatch('startFetching', { timeline: 'user', userId: this.userId })
this.$store.dispatch('startFetching', { timeline: 'media', userId: this.userId })
this.startFetchFavorites() this.startFetchFavorites()
}
}, },
cleanUp () { cleanUp () {
this.$store.dispatch('stopFetching', 'user') this.$store.dispatch('stopFetching', 'user')
@ -128,23 +136,26 @@ const UserProfile = {
} }
}, },
watch: { watch: {
userName () { // userId can be undefined if we don't know it yet
if (this.isExternal) { userId (newVal) {
return if (newVal) {
this.cleanUp()
this.startUp()
} }
this.cleanUp()
this.startUp()
}, },
userId () { userName () {
if (!this.isExternal) { if (this.$route.params.name) {
return this.fetchUserId()
this.cleanUp()
this.startUp()
} }
this.cleanUp() },
this.startUp() $route () {
this.$refs.tabSwitcher.activateTab(0)()
} }
}, },
components: { components: {
UserCardContent, UserCard,
Timeline, Timeline,
FollowerList, FollowerList,
FriendList FriendList

View file

@ -1,12 +1,8 @@
<template> <template>
<div> <div>
<div v-if="user.id" class="user-profile panel panel-default"> <div v-if="user.id" class="user-profile panel panel-default">
<user-card-content <UserCard :user="user" :switcher="true" :selected="timeline.viewing" rounded="top"/>
:user="user" <tab-switcher :renderOnlyFocused="true" ref="tabSwitcher">
:switcher="true"
:selected="timeline.viewing"
/>
<tab-switcher :renderOnlyFocused="true">
<Timeline <Timeline
:label="$t('user_card.statuses')" :label="$t('user_card.statuses')"
:disabled="!user.statuses_count" :disabled="!user.statuses_count"
@ -15,7 +11,7 @@
:title="$t('user_profile.timeline_title')" :title="$t('user_profile.timeline_title')"
:timeline="timeline" :timeline="timeline"
:timeline-name="'user'" :timeline-name="'user'"
:user-id="fetchBy" :user-id="userId"
/> />
<div :label="$t('user_card.followees')" v-if="followsTabVisible" :disabled="!user.friends_count"> <div :label="$t('user_card.followees')" v-if="followsTabVisible" :disabled="!user.friends_count">
<FriendList :userId="userId" /> <FriendList :userId="userId" />
@ -29,7 +25,7 @@
:embedded="true" :title="$t('user_card.media')" :embedded="true" :title="$t('user_card.media')"
timeline-name="media" timeline-name="media"
:timeline="media" :timeline="media"
:user-id="fetchBy" :user-id="userId"
/> />
<Timeline <Timeline
v-if="isUs" v-if="isUs"
@ -64,11 +60,6 @@
flex: 2; flex: 2;
flex-basis: 500px; flex-basis: 500px;
.profile-panel-background .panel-heading {
background: transparent;
flex-direction: column;
align-items: stretch;
}
.userlist-placeholder { .userlist-placeholder {
display: flex; display: flex;
justify-content: center; justify-content: center;

View file

@ -8,6 +8,7 @@ import ScopeSelector from '../scope_selector/scope_selector.vue'
import fileSizeFormatService from '../../services/file_size_format/file_size_format.js' import fileSizeFormatService from '../../services/file_size_format/file_size_format.js'
import BlockCard from '../block_card/block_card.vue' import BlockCard from '../block_card/block_card.vue'
import MuteCard from '../mute_card/mute_card.vue' import MuteCard from '../mute_card/mute_card.vue'
import EmojiInput from '../emoji-input/emoji-input.vue'
import withSubscription from '../../hocs/with_subscription/with_subscription' import withSubscription from '../../hocs/with_subscription/with_subscription'
import withList from '../../hocs/with_list/with_list' import withList from '../../hocs/with_list/with_list'
@ -71,7 +72,8 @@ const UserSettings = {
TabSwitcher, TabSwitcher,
ImageCropper, ImageCropper,
BlockList, BlockList,
MuteList MuteList,
EmojiInput
}, },
computed: { computed: {
user () { user () {
@ -159,8 +161,14 @@ const UserSettings = {
} }
reader.readAsDataURL(file) reader.readAsDataURL(file)
}, },
submitAvatar (cropper) { submitAvatar (cropper, file) {
const img = cropper.getCroppedCanvas().toDataURL('image/jpeg') let img
if (cropper) {
img = cropper.getCroppedCanvas().toDataURL(file.type)
} else {
img = file
}
return this.$store.state.api.backendInteractor.updateAvatar({ params: { img } }).then((user) => { return this.$store.state.api.backendInteractor.updateAvatar({ params: { img } }).then((user) => {
if (!user.error) { if (!user.error) {
this.$store.commit('addNewUsers', [user]) this.$store.commit('addNewUsers', [user])

View file

@ -22,9 +22,18 @@
<div class="setting-item" > <div class="setting-item" >
<h2>{{$t('settings.name_bio')}}</h2> <h2>{{$t('settings.name_bio')}}</h2>
<p>{{$t('settings.name')}}</p> <p>{{$t('settings.name')}}</p>
<input class='name-changer' id='username' v-model="newName"></input> <EmojiInput
type="text"
v-model="newName"
id="username"
classname="name-changer"
/>
<p>{{$t('settings.bio')}}</p> <p>{{$t('settings.bio')}}</p>
<textarea class="bio" v-model="newBio"></textarea> <EmojiInput
type="textarea"
v-model="newBio"
classname="bio"
/>
<p> <p>
<input type="checkbox" v-model="newLocked" id="account-locked"> <input type="checkbox" v-model="newLocked" id="account-locked">
<label for="account-locked">{{$t('settings.lock_account_description')}}</label> <label for="account-locked">{{$t('settings.lock_account_description')}}</label>
@ -61,7 +70,7 @@
<h2>{{$t('settings.avatar')}}</h2> <h2>{{$t('settings.avatar')}}</h2>
<p class="visibility-notice">{{$t('settings.avatar_size_instruction')}}</p> <p class="visibility-notice">{{$t('settings.avatar_size_instruction')}}</p>
<p>{{$t('settings.current_avatar')}}</p> <p>{{$t('settings.current_avatar')}}</p>
<img :src="user.profile_image_url_original" class="current-avatar"></img> <img :src="user.profile_image_url_original" class="current-avatar" />
<p>{{$t('settings.set_new_avatar')}}</p> <p>{{$t('settings.set_new_avatar')}}</p>
<button class="btn" type="button" id="pick-avatar" v-show="pickAvatarBtnVisible">{{$t('settings.upload_a_photo')}}</button> <button class="btn" type="button" id="pick-avatar" v-show="pickAvatarBtnVisible">{{$t('settings.upload_a_photo')}}</button>
<image-cropper trigger="#pick-avatar" :submitHandler="submitAvatar" @open="pickAvatarBtnVisible=false" @close="pickAvatarBtnVisible=true" /> <image-cropper trigger="#pick-avatar" :submitHandler="submitAvatar" @open="pickAvatarBtnVisible=false" @close="pickAvatarBtnVisible=true" />
@ -69,12 +78,11 @@
<div class="setting-item"> <div class="setting-item">
<h2>{{$t('settings.profile_banner')}}</h2> <h2>{{$t('settings.profile_banner')}}</h2>
<p>{{$t('settings.current_profile_banner')}}</p> <p>{{$t('settings.current_profile_banner')}}</p>
<img :src="user.cover_photo" class="banner"></img> <img :src="user.cover_photo" class="banner" />
<p>{{$t('settings.set_new_profile_banner')}}</p> <p>{{$t('settings.set_new_profile_banner')}}</p>
<img class="banner" v-bind:src="bannerPreview" v-if="bannerPreview"> <img class="banner" v-bind:src="bannerPreview" v-if="bannerPreview" />
</img>
<div> <div>
<input type="file" @change="uploadFile('banner', $event)" ></input> <input type="file" @change="uploadFile('banner', $event)" />
</div> </div>
<i class=" icon-spin4 animate-spin uploading" v-if="bannerUploading"></i> <i class=" icon-spin4 animate-spin uploading" v-if="bannerUploading"></i>
<button class="btn btn-default" v-else-if="bannerPreview" @click="submitBanner">{{$t('general.submit')}}</button> <button class="btn btn-default" v-else-if="bannerPreview" @click="submitBanner">{{$t('general.submit')}}</button>
@ -86,10 +94,9 @@
<div class="setting-item"> <div class="setting-item">
<h2>{{$t('settings.profile_background')}}</h2> <h2>{{$t('settings.profile_background')}}</h2>
<p>{{$t('settings.set_new_profile_background')}}</p> <p>{{$t('settings.set_new_profile_background')}}</p>
<img class="bg" v-bind:src="backgroundPreview" v-if="backgroundPreview"> <img class="bg" v-bind:src="backgroundPreview" v-if="backgroundPreview" />
</img>
<div> <div>
<input type="file" @change="uploadFile('background', $event)" ></input> <input type="file" @change="uploadFile('background', $event)" />
</div> </div>
<i class=" icon-spin4 animate-spin uploading" v-if="backgroundUploading"></i> <i class=" icon-spin4 animate-spin uploading" v-if="backgroundUploading"></i>
<button class="btn btn-default" v-else-if="backgroundPreview" @click="submitBg">{{$t('general.submit')}}</button> <button class="btn btn-default" v-else-if="backgroundPreview" @click="submitBg">{{$t('general.submit')}}</button>
@ -165,7 +172,7 @@
<h2>{{$t('settings.follow_import')}}</h2> <h2>{{$t('settings.follow_import')}}</h2>
<p>{{$t('settings.import_followers_from_a_csv_file')}}</p> <p>{{$t('settings.import_followers_from_a_csv_file')}}</p>
<form> <form>
<input type="file" ref="followlist" v-on:change="followListChange"></input> <input type="file" ref="followlist" v-on:change="followListChange" />
</form> </form>
<i class=" icon-spin4 animate-spin uploading" v-if="followListUploading"></i> <i class=" icon-spin4 animate-spin uploading" v-if="followListUploading"></i>
<button class="btn btn-default" v-else @click="importFollows">{{$t('general.submit')}}</button> <button class="btn btn-default" v-else @click="importFollows">{{$t('general.submit')}}</button>
@ -192,6 +199,12 @@
<template slot="empty">{{$t('settings.no_blocks')}}</template> <template slot="empty">{{$t('settings.no_blocks')}}</template>
</block-list> </block-list>
</div> </div>
<div :label="$t('settings.mutes_tab')">
<mute-list :refresh="true">
<template slot="empty">{{$t('settings.no_mutes')}}</template>
</mute-list>
</div>
</tab-switcher> </tab-switcher>
</div> </div>
</div> </div>

View file

@ -49,7 +49,7 @@
"account_not_locked_warning_link": "مقفل", "account_not_locked_warning_link": "مقفل",
"attachments_sensitive": "اعتبر المرفقات كلها كمحتوى حساس", "attachments_sensitive": "اعتبر المرفقات كلها كمحتوى حساس",
"content_type": { "content_type": {
"plain_text": "نص صافٍ" "text/plain": "نص صافٍ"
}, },
"content_warning": "الموضوع (اختياري)", "content_warning": "الموضوع (اختياري)",
"default": "وصلت للتوّ إلى لوس أنجلس.", "default": "وصلت للتوّ إلى لوس أنجلس.",

View file

@ -49,7 +49,7 @@
"account_not_locked_warning_link": "bloquejat", "account_not_locked_warning_link": "bloquejat",
"attachments_sensitive": "Marca l'adjunt com a delicat", "attachments_sensitive": "Marca l'adjunt com a delicat",
"content_type": { "content_type": {
"plain_text": "Text pla" "text/plain": "Text pla"
}, },
"content_warning": "Assumpte (opcional)", "content_warning": "Assumpte (opcional)",
"default": "Em sento…", "default": "Em sento…",

428
src/i18n/cs.json Normal file
View file

@ -0,0 +1,428 @@
{
"chat": {
"title": "Chat"
},
"features_panel": {
"chat": "Chat",
"gopher": "Gopher",
"media_proxy": "Mediální proxy",
"scope_options": "Možnosti rozsahů",
"text_limit": "Textový limit",
"title": "Vlastnosti",
"who_to_follow": "Koho sledovat"
},
"finder": {
"error_fetching_user": "Chyba při načítání uživatele",
"find_user": "Najít uživatele"
},
"general": {
"apply": "Použít",
"submit": "Odeslat",
"more": "Více",
"generic_error": "Vyskytla se chyba",
"optional": "volitelné"
},
"image_cropper": {
"crop_picture": "Oříznout obrázek",
"save": "Uložit",
"cancel": "Zrušit"
},
"login": {
"login": "Přihlásit",
"description": "Přihlásit pomocí OAuth",
"logout": "Odhlásit",
"password": "Heslo",
"placeholder": "např. lain",
"register": "Registrovat",
"username": "Uživatelské jméno",
"hint": "Chcete-li se přidat do diskuze, přihlaste se"
},
"media_modal": {
"previous": "Předchozí",
"next": "Další"
},
"nav": {
"about": "O instanci",
"back": "Zpět",
"chat": "Místní chat",
"friend_requests": "Požadavky o sledování",
"mentions": "Zmínky",
"dms": "Přímé zprávy",
"public_tl": "Veřejná časová osa",
"timeline": "Časová osa",
"twkn": "Celá známá síť",
"user_search": "Hledání uživatelů",
"who_to_follow": "Koho sledovat",
"preferences": "Předvolby"
},
"notifications": {
"broken_favorite": "Neznámý příspěvek, hledám jej…",
"favorited_you": "si oblíbil/a váš příspěvek",
"followed_you": "vás nyní sleduje",
"load_older": "Načíst starší oznámení",
"notifications": "Oznámení",
"read": "Číst!",
"repeated_you": "zopakoval/a váš příspěvek",
"no_more_notifications": "Žádná další oznámení"
},
"post_status": {
"new_status": "Napsat nový příspěvek",
"account_not_locked_warning": "Váš účet není {0}. Kdokoliv vás může sledovat a vidět vaše příspěvky pouze pro sledující.",
"account_not_locked_warning_link": "uzamčen",
"attachments_sensitive": "Označovat přílohy jako citlivé",
"content_type": {
"text/plain": "Prostý text",
"text/html": "HTML",
"text/markdown": "Markdown"
},
"content_warning": "Předmět (volitelný)",
"default": "Právě jsem přistál v L.A.",
"direct_warning": "Tento příspěvek uvidí pouze všichni zmínění uživatelé.",
"posting": "Přispívání",
"scope": {
"direct": "Přímý - Poslat pouze zmíněným uživatelům",
"private": "Pouze pro sledující - Poslat pouze sledujícím",
"public": "Veřejný - Poslat na veřejné časové osy",
"unlisted": "Neuvedený - Neposlat na veřejné časové osy"
}
},
"registration": {
"bio": "O vás",
"email": "E-mail",
"fullname": "Zobrazované jméno",
"password_confirm": "Potvrzení hesla",
"registration": "Registrace",
"token": "Token pozvánky",
"captcha": "CAPTCHA",
"new_captcha": "Kliknutím na obrázek získáte novou CAPTCHA",
"username_placeholder": "např. lain",
"fullname_placeholder": "např. Lain Iwakura",
"bio_placeholder": "např.\nNazdar, jsem Lain\nJsem anime dívka žijící v příměstském Japonsku. Možná mě znáte z Wired.",
"validations": {
"username_required": "nemůže být prázdné",
"fullname_required": "nemůže být prázdné",
"email_required": "nemůže být prázdný",
"password_required": "nemůže být prázdné",
"password_confirmation_required": "nemůže být prázdné",
"password_confirmation_match": "musí být stejné jako heslo"
}
},
"settings": {
"app_name": "Název aplikace",
"attachmentRadius": "Přílohy",
"attachments": "Přílohy",
"autoload": "Povolit automatické načítání při rolování dolů",
"avatar": "Avatar",
"avatarAltRadius": "Avatary (oznámení)",
"avatarRadius": "Avatary",
"background": "Pozadí",
"bio": "O vás",
"blocks_tab": "Blokování",
"btnRadius": "Tlačítka",
"cBlue": "Modrá (Odpovědět, sledovat)",
"cGreen": "Zelená (Zopakovat)",
"cOrange": "Oranžová (Oblíbit)",
"cRed": "Červená (Zrušit)",
"change_password": "Změnit heslo",
"change_password_error": "Při změně vašeho hesla se vyskytla chyba.",
"changed_password": "Heslo bylo úspěšně změněno!",
"collapse_subject": "Zabalit příspěvky s předměty",
"composing": "Komponování",
"confirm_new_password": "Potvrďte nové heslo",
"current_avatar": "Váš současný avatar",
"current_password": "Současné heslo",
"current_profile_banner": "Váš současný profilový banner",
"data_import_export_tab": "Import/export dat",
"default_vis": "Výchozí rozsah viditelnosti",
"delete_account": "Smazat účet",
"delete_account_description": "Trvale smaže váš účet a všechny vaše příspěvky.",
"delete_account_error": "Při mazání vašeho účtu nastala chyba. Pokud tato chyba bude trvat, kontaktujte prosím admministrátora vaší instance.",
"delete_account_instructions": "Pro potvrzení smazání účtu napište své heslo do pole níže.",
"avatar_size_instruction": "Doporučená minimální velikost pro avatarové obrázky je 150x150 pixelů.",
"export_theme": "Uložit přednastavení",
"filtering": "Filtrování",
"filtering_explanation": "Všechny příspěvky obsahující tato slova budou skryty. Napište jedno slovo na každý řádek",
"follow_export": "Export sledovaných",
"follow_export_button": "Exportovat vaše sledované do souboru CSV",
"follow_export_processing": "Zpracovávám, brzy si budete moci stáhnout váš soubor",
"follow_import": "Import sledovaných",
"follow_import_error": "Chyba při importování sledovaných",
"follows_imported": "Sledovaní importováni! Jejich zpracování bude chvilku trvat.",
"foreground": "Popředí",
"general": "Obecné",
"hide_attachments_in_convo": "Skrývat přílohy v konverzacích",
"hide_attachments_in_tl": "Skrývat přílohy v časové ose",
"max_thumbnails": "Maximální počet miniatur na příspěvek",
"hide_isp": "Skrýt panel specifický pro instanci",
"preload_images": "Přednačítat obrázky",
"use_one_click_nsfw": "Otevírat citlivé přílohy pouze jedním kliknutím",
"hide_post_stats": "Skrývat statistiky příspěvků (např. počet oblíbení)",
"hide_user_stats": "Skrývat statistiky uživatelů (např. počet sledujících)",
"hide_filtered_statuses": "Skrývat filtrované příspěvky",
"import_followers_from_a_csv_file": "Importovat sledované ze souboru CSV",
"import_theme": "Načíst přednastavení",
"inputRadius": "Vstupní pole",
"checkboxRadius": "Zaškrtávací pole",
"instance_default": "(výchozí: {value})",
"instance_default_simple": "(výchozí)",
"interface": "Rozhraní",
"interfaceLanguage": "Jazyk rozhraní",
"invalid_theme_imported": "Zvolený soubor není podporovaný motiv Pleroma. Nebyly provedeny žádné změny s vaším motivem.",
"limited_availability": "Nedostupné ve vašem prohlížeči",
"links": "Odkazy",
"lock_account_description": "Omezit váš účet pouze na schválené sledující",
"loop_video": "Opakovat videa",
"loop_video_silent_only": "Opakovat pouze videa beze zvuku (t.j. „GIFy“ na Mastodonu)",
"mutes_tab": "Ignorování",
"play_videos_in_modal": "Přehrávat videa přímo v prohlížeči médií",
"use_contain_fit": "Neořezávat přílohu v miniaturách",
"name": "Jméno",
"name_bio": "Jméno a popis",
"new_password": "Nové heslo",
"notification_visibility": "Typy oznámení k zobrazení",
"notification_visibility_follows": "Sledující",
"notification_visibility_likes": "Oblíbení",
"notification_visibility_mentions": "Zmínky",
"notification_visibility_repeats": "Zopakování",
"no_rich_text_description": "Odstranit ze všech příspěvků formátování textu",
"no_blocks": "Žádná blokování",
"no_mutes": "Žádná ignorování",
"hide_follows_description": "Nezobrazovat, koho sleduji",
"hide_followers_description": "Nezobrazovat, kdo mě sleduje",
"show_admin_badge": "Zobrazovat v mém profilu odznak administrátora",
"show_moderator_badge": "Zobrazovat v mém profilu odznak moderátora",
"nsfw_clickthrough": "Povolit prokliknutelné skrývání citlivých příloh",
"oauth_tokens": "Tokeny OAuth",
"token": "Token",
"refresh_token": "Obnovit token",
"valid_until": "Platný do",
"revoke_token": "Odvolat",
"panelRadius": "Panely",
"pause_on_unfocused": "Pozastavit streamování, pokud není záložka prohlížeče v soustředění",
"presets": "Přednastavení",
"profile_background": "Profilové pozadí",
"profile_banner": "Profilový banner",
"profile_tab": "Profil",
"radii_help": "Nastavit zakulacení rohů rozhraní (v pixelech)",
"replies_in_timeline": "Odpovědi v časové ose",
"reply_link_preview": "Povolit náhledy odkazu pro odpověď při přejetí myši",
"reply_visibility_all": "Zobrazit všechny odpovědi",
"reply_visibility_following": "Zobrazit pouze odpovědi směřované na mě nebo uživatele, které sleduji",
"reply_visibility_self": "Zobrazit pouze odpovědi směřované na mě",
"saving_err": "Chyba při ukládání nastavení",
"saving_ok": "Nastavení uložena",
"security_tab": "Bezpečnost",
"scope_copy": "Kopírovat rozsah při odpovídání (přímé zprávy jsou vždy kopírovány)",
"set_new_avatar": "Nastavit nový avatar",
"set_new_profile_background": "Nastavit nové profilové pozadí",
"set_new_profile_banner": "Nastavit nový profilový banner",
"settings": "Nastavení",
"subject_input_always_show": "Vždy zobrazit pole pro předmět",
"subject_line_behavior": "Kopírovat předmět při odpovídání",
"subject_line_email": "Jako u e-mailu: „re: předmět“",
"subject_line_mastodon": "Jako u Mastodonu: zkopírovat tak, jak je",
"subject_line_noop": "Nekopírovat",
"post_status_content_type": "Publikovat typ obsahu příspěvku",
"stop_gifs": "Přehrávat GIFy při přejetí myši",
"streaming": "Povolit automatické streamování nových příspěvků při rolování nahoru",
"text": "Text",
"theme": "Motiv",
"theme_help": "Použijte hexadecimální barevné kódy (#rrggbb) pro přizpůsobení vašeho barevného motivu.",
"theme_help_v2_1": "Zaškrtnutím pole můžete také přepsat barvy a průhlednost některých komponentů, pro smazání všech přednastavení použijte tlačítko „Smazat vše“.",
"theme_help_v2_2": "Ikony pod některými položkami jsou indikátory kontrastu pozadí/textu, pro detailní informace nad nimi přejeďte myší. Prosím berte na vědomí, že při používání kontrastu průhlednosti ukazují indikátory nejhorší možný případ.",
"tooltipRadius": "Popisky/upozornění",
"upload_a_photo": "Nahrát fotku",
"user_settings": "Uživatelská nastavení",
"values": {
"false": "ne",
"true": "ano"
},
"notifications": "Oznámení",
"enable_web_push_notifications": "Povolit webová push oznámení",
"style": {
"switcher": {
"keep_color": "Ponechat barvy",
"keep_shadows": "Ponechat stíny",
"keep_opacity": "Ponechat průhlednost",
"keep_roundness": "Ponechat kulatost",
"keep_fonts": "Keep fonts",
"save_load_hint": "Možnosti „Ponechat“ dočasně ponechávají aktuálně nastavené možností při volení či nahrávání motivů, také tyto možnosti ukládají při exportování motivu. Pokud není žádné pole zaškrtnuto, uloží export motivu všechno.",
"reset": "Resetovat",
"clear_all": "Vymazat vše",
"clear_opacity": "Vymazat průhlednost"
},
"common": {
"color": "Barva",
"opacity": "Průhlednost",
"contrast": {
"hint": "Poměr kontrastu je {ratio}, {level} {context}",
"level": {
"aa": "splňuje směrnici úrovně AA (minimální)",
"aaa": "splňuje směrnici úrovně AAA (doporučováno)",
"bad": "nesplňuje žádné směrnice přístupnosti"
},
"context": {
"18pt": "pro velký (18+ bodů) text",
"text": "pro text"
}
}
},
"common_colors": {
"_tab_label": "Obvyklé",
"main": "Obvyklé barvy",
"foreground_hint": "Pro detailnější kontrolu viz záložka „Pokročilé“",
"rgbo": "Ikony, odstíny, odznaky"
},
"advanced_colors": {
"_tab_label": "Pokročilé",
"alert": "Pozadí upozornění",
"alert_error": "Chyba",
"badge": "Pozadí odznaků",
"badge_notification": "Oznámení",
"panel_header": "Záhlaví panelu",
"top_bar": "Vrchní pruh",
"borders": "Okraje",
"buttons": "Tlačítka",
"inputs": "Vstupní pole",
"faint_text": "Vybledlý text"
},
"radii": {
"_tab_label": "Kulatost"
},
"shadows": {
"_tab_label": "Stín a osvětlení",
"component": "Komponent",
"override": "Přepsat",
"shadow_id": "Stín #{value}",
"blur": "Rozmazání",
"spread": "Rozsah",
"inset": "Vsazení",
"hint": "Pro stíny můžete také použít --variable jako hodnotu barvy pro použití proměnných CSS3. Prosím berte na vědomí, že nastavení průhlednosti v tomto případě nebude fungovat.",
"filter_hint": {
"always_drop_shadow": "Varování, tento stín vždy používá {0}, když to prohlížeč podporuje.",
"drop_shadow_syntax": "{0} nepodporuje parametr {1} a klíčové slovo {2}.",
"avatar_inset": "Prosím berte na vědomí, že kombinování vsazených i nevsazených stínů u avatarů může u průhledných avatarů dát neočekávané výsledky.",
"spread_zero": "Stíny s rozsahem > 0 se zobrazí, jako kdyby byl rozsah nastaven na nulu",
"inset_classic": "Vsazené stíny budou používat {0}"
},
"components": {
"panel": "Panel",
"panelHeader": "Záhlaví panelu",
"topBar": "Vrchní pruh",
"avatar": "Avatar uživatele (v zobrazení profilu)",
"avatarStatus": "Avatar uživatele (v zobrazení příspěvku)",
"popup": "Vyskakovací okna a popisky",
"button": "Tlačítko",
"buttonHover": "Tlačítko (přejetí myši)",
"buttonPressed": "Tlačítko (stisknuto)",
"buttonPressedHover": "Button (stisknuto+přejetí myši)",
"input": "Vstupní pole"
}
},
"fonts": {
"_tab_label": "Písma",
"help": "Zvolte písmo, které bude použito pro prvky rozhraní. U možnosti „vlastní“ musíte zadat přesný název písma tak, jak se zobrazuje v systému.",
"components": {
"interface": "Rozhraní",
"input": "Vstupní pole",
"post": "Text příspěvků",
"postCode": "Neproporcionální text v příspěvku (formátovaný text)"
},
"family": "Název písma",
"size": "Velikost (v pixelech)",
"weight": "Tloušťka",
"custom": "Vlastní"
},
"preview": {
"header": "Náhled",
"content": "Obsah",
"error": "Příklad chyby",
"button": "Tlačítko",
"text": "Spousta dalšího {0} a {1}",
"mono": "obsahu",
"input": "Právě jsem přistál v L.A.",
"faint_link": "pomocný manuál",
"fine_print": "Přečtěte si náš {0} a nenaučte se nic užitečného!",
"header_faint": "Tohle je v pohodě",
"checkbox": "Pročetl/a jsem podmínky používání",
"link": "hezký malý odkaz"
}
}
},
"timeline": {
"collapse": "Zabalit",
"conversation": "Konverzace",
"error_fetching": "Chyba při načítání aktualizací",
"load_older": "Načíst starší příspěvky",
"no_retweet_hint": "Příspěvek je označen jako pouze pro sledující či přímý a nemůže být zopakován",
"repeated": "zopakoval/a",
"show_new": "Zobrazit nové",
"up_to_date": "Aktuální",
"no_more_statuses": "Žádné další příspěvky",
"no_statuses": "Žádné příspěvky"
},
"status": {
"reply_to": "Odpověď uživateli",
"replies_list": "Odpovědi:"
},
"user_card": {
"approve": "Schválit",
"block": "Blokovat",
"blocked": "Blokován/a!",
"deny": "Zamítnout",
"favorites": "Oblíbené",
"follow": "Sledovat",
"follow_sent": "Požadavek odeslán!",
"follow_progress": "Odeslílám požadavek…",
"follow_again": "Odeslat požadavek znovu?",
"follow_unfollow": "Přestat sledovat",
"followees": "Sledovaní",
"followers": "Sledující",
"following": "Sledujete!",
"follows_you": "Sleduje vás!",
"its_you": "Jste to vy!",
"media": "Média",
"mute": "Ignorovat",
"muted": "Ignorován/a",
"per_day": "za den",
"remote_follow": "Vzdálené sledování",
"statuses": "Příspěvky",
"unblock": "Odblokovat",
"unblock_progress": "Odblokuji…",
"block_progress": "Blokuji…",
"unmute": "Přestat ignorovat",
"unmute_progress": "Ruším ignorování…",
"mute_progress": "Ignoruji…"
},
"user_profile": {
"timeline_title": "Uživatelská časová osa",
"profile_does_not_exist": "Omlouváme se, tento profil neexistuje.",
"profile_loading_error": "Omlouváme se, při načítání tohoto profilu se vyskytla chyba."
},
"who_to_follow": {
"more": "Více",
"who_to_follow": "Koho sledovat"
},
"tool_tip": {
"media_upload": "Nahrát média",
"repeat": "Zopakovat",
"reply": "Odpovědět",
"favorite": "Oblíbit",
"user_settings": "Uživatelské nastavení"
},
"upload":{
"error": {
"base": "Nahrávání selhalo.",
"file_too_big": "Soubor je příliš velký [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]",
"default": "Zkuste to znovu později"
},
"file_size_units": {
"B": "B",
"KiB": "KiB",
"MiB": "MiB",
"GiB": "GiB",
"TiB": "TiB"
}
}
}

View file

@ -55,7 +55,7 @@
"account_not_locked_warning_link": "gesperrt", "account_not_locked_warning_link": "gesperrt",
"attachments_sensitive": "Anhänge als heikel markieren", "attachments_sensitive": "Anhänge als heikel markieren",
"content_type": { "content_type": {
"plain_text": "Nur Text" "text/plain": "Nur Text"
}, },
"content_warning": "Betreff (optional)", "content_warning": "Betreff (optional)",
"default": "Sitze gerade im Hofbräuhaus.", "default": "Sitze gerade im Hofbräuhaus.",

View file

@ -25,6 +25,7 @@
"image_cropper": { "image_cropper": {
"crop_picture": "Crop picture", "crop_picture": "Crop picture",
"save": "Save", "save": "Save",
"save_without_cropping": "Save without cropping",
"cancel": "Cancel" "cancel": "Cancel"
}, },
"login": { "login": {
@ -71,7 +72,9 @@
"account_not_locked_warning_link": "locked", "account_not_locked_warning_link": "locked",
"attachments_sensitive": "Mark attachments as sensitive", "attachments_sensitive": "Mark attachments as sensitive",
"content_type": { "content_type": {
"plain_text": "Plain text" "text/plain": "Plain text",
"text/html": "HTML",
"text/markdown": "Markdown"
}, },
"content_warning": "Subject (optional)", "content_warning": "Subject (optional)",
"default": "Just landed in L.A.", "default": "Just landed in L.A.",
@ -95,7 +98,7 @@
"new_captcha": "Click the image to get a new captcha", "new_captcha": "Click the image to get a new captcha",
"username_placeholder": "e.g. lain", "username_placeholder": "e.g. lain",
"fullname_placeholder": "e.g. Lain Iwakura", "fullname_placeholder": "e.g. Lain Iwakura",
"bio_placeholder": "e.g.\nHi, I'm Lain\nIm an anime girl living in suburban Japan. You may know me from the Wired.", "bio_placeholder": "e.g.\nHi, I'm Lain.\nIm an anime girl living in suburban Japan. You may know me from the Wired.",
"validations": { "validations": {
"username_required": "cannot be left blank", "username_required": "cannot be left blank",
"fullname_required": "cannot be left blank", "fullname_required": "cannot be left blank",
@ -150,6 +153,7 @@
"general": "General", "general": "General",
"hide_attachments_in_convo": "Hide attachments in conversations", "hide_attachments_in_convo": "Hide attachments in conversations",
"hide_attachments_in_tl": "Hide attachments in timeline", "hide_attachments_in_tl": "Hide attachments in timeline",
"hide_muted_posts": "Hide posts of muted users",
"max_thumbnails": "Maximum amount of thumbnails per post", "max_thumbnails": "Maximum amount of thumbnails per post",
"hide_isp": "Hide instance-specific panel", "hide_isp": "Hide instance-specific panel",
"preload_images": "Preload images", "preload_images": "Preload images",
@ -222,7 +226,6 @@
"subject_line_mastodon": "Like mastodon: copy as is", "subject_line_mastodon": "Like mastodon: copy as is",
"subject_line_noop": "Do not copy", "subject_line_noop": "Do not copy",
"post_status_content_type": "Post status content type", "post_status_content_type": "Post status content type",
"status_content_type_plain": "Plain text",
"stop_gifs": "Play-on-hover GIFs", "stop_gifs": "Play-on-hover GIFs",
"streaming": "Enable automatic streaming of new posts when scrolled to the top", "streaming": "Enable automatic streaming of new posts when scrolled to the top",
"text": "Text", "text": "Text",
@ -347,6 +350,11 @@
"checkbox": "I have skimmed over terms and conditions", "checkbox": "I have skimmed over terms and conditions",
"link": "a nice lil' link" "link": "a nice lil' link"
} }
},
"version": {
"title": "Version",
"backend_version": "Backend Version",
"frontend_version": "Frontend Version"
} }
}, },
"timeline": { "timeline": {

View file

@ -2,118 +2,420 @@
"chat": { "chat": {
"title": "Babilejo" "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": { "finder": {
"error_fetching_user": "Eraro alportante uzanton", "error_fetching_user": "Eraro alportante uzanton",
"find_user": "Trovi uzanton" "find_user": "Trovi uzanton"
}, },
"general": { "general": {
"apply": "Apliki", "apply": "Apliki",
"submit": "Sendi" "submit": "Sendi",
"more": "Pli",
"generic_error": "Eraro okazis",
"optional": "Malnepra"
},
"image_cropper": {
"crop_picture": "Tondi bildon",
"save": "Konservi",
"cancel": "Nuligi"
}, },
"login": { "login": {
"login": "Ensaluti", "login": "Saluti",
"logout": "Elsaluti", "description": "Saluti per OAuth",
"logout": "Adiaŭi",
"password": "Pasvorto", "password": "Pasvorto",
"placeholder": "ekz. lain", "placeholder": "ekz. lain",
"register": "Registriĝi", "register": "Registriĝi",
"username": "Salutnomo" "username": "Salutnomo",
"hint": "Salutu por partopreni la diskutadon"
},
"media_modal": {
"previous": "Antaŭa",
"next": "Sekva"
}, },
"nav": { "nav": {
"about": "Pri",
"back": "Reen",
"chat": "Loka babilejo", "chat": "Loka babilejo",
"friend_requests": "Abonaj petoj",
"mentions": "Mencioj", "mentions": "Mencioj",
"dms": "Rektaj mesaĝoj",
"public_tl": "Publika tempolinio", "public_tl": "Publika tempolinio",
"timeline": "Tempolinio", "timeline": "Tempolinio",
"twkn": "La tuta konata reto" "twkn": "La tuta konata reto",
"user_search": "Serĉi uzantojn",
"who_to_follow": "Kiun aboni",
"preferences": "Agordoj"
}, },
"notifications": { "notifications": {
"broken_favorite": "Nekonata stato, serĉante ĝin…",
"favorited_you": "ŝatis vian staton", "favorited_you": "ŝatis vian staton",
"followed_you": "ekabonis vin", "followed_you": "ekabonis vin",
"load_older": "Enlegi pli malnovajn sciigojn",
"notifications": "Sciigoj", "notifications": "Sciigoj",
"read": "Legite!", "read": "Legite!",
"repeated_you": "ripetis vian staton" "repeated_you": "ripetis vian staton",
"no_more_notifications": "Neniuj pliaj sciigoj"
}, },
"post_status": { "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": {
"text/plain": "Plata teksto"
},
"content_warning": "Temo (malnepra)",
"default": "Ĵus alvenis al la Universala Kongreso!", "default": "Ĵus alvenis al la Universala Kongreso!",
"posting": "Afiŝante" "direct_warning": "Ĉi tiu afiŝo estos videbla nur por ĉiuj menciitaj uzantoj.",
"posting": "Afiŝante",
"scope": {
"direct": "Rekta Afiŝi nur al menciitaj uzantoj",
"private": "Nur abonantoj Afiŝi nur al abonantoj",
"public": "Publika Afiŝi al publikaj tempolinioj",
"unlisted": "Nelistigita Ne afiŝi al publikaj tempolinioj"
}
}, },
"registration": { "registration": {
"bio": "Priskribo", "bio": "Priskribo",
"email": "Retpoŝtadreso", "email": "Retpoŝtadreso",
"fullname": "Vidiga nomo", "fullname": "Vidiga nomo",
"password_confirm": "Konfirmo de pasvorto", "password_confirm": "Konfirmo de pasvorto",
"registration": "Registriĝo" "registration": "Registriĝo",
"token": "Invita ĵetono",
"captcha": "TESTO DE HOMECO",
"new_captcha": "Alklaku la bildon por akiri novan teston",
"username_placeholder": "ekz. lain",
"fullname_placeholder": "ekz. Lain Iwakura",
"bio_placeholder": "ekz.\nSaluton, mi estas Lain\nMi estas animea knabino vivante en Japanujo. Eble vi konas min de la retejo «Wired».",
"validations": {
"username_required": "ne povas resti malplena",
"fullname_required": "ne povas resti malplena",
"email_required": "ne povas resti malplena",
"password_required": "ne povas resti malplena",
"password_confirmation_required": "ne povas resti malplena",
"password_confirmation_match": "samu la pasvorton"
}
}, },
"settings": { "settings": {
"app_name": "Nomo de aplikaĵo",
"attachmentRadius": "Kunsendaĵoj", "attachmentRadius": "Kunsendaĵoj",
"attachments": "Kunsendaĵoj", "attachments": "Kunsendaĵoj",
"autoload": "Ŝalti memfaran ŝarĝadon ĉe subo de paĝo", "autoload": "Ŝalti memfaran enlegadon ĉe subo de paĝo",
"avatar": "Profilbildo", "avatar": "Profilbildo",
"avatarAltRadius": "Profilbildoj (sciigoj)", "avatarAltRadius": "Profilbildoj (sciigoj)",
"avatarRadius": "Profilbildoj", "avatarRadius": "Profilbildoj",
"background": "Fono", "background": "Fono",
"bio": "Priskribo", "bio": "Priskribo",
"blocks_tab": "Baroj",
"btnRadius": "Butonoj", "btnRadius": "Butonoj",
"cBlue": "Blua (Respondo, abono)", "cBlue": "Blua (Respondo, abono)",
"cGreen": "Verda (Kunhavigo)", "cGreen": "Verda (Kunhavigo)",
"cOrange": "Oranĝa (Ŝato)", "cOrange": "Oranĝa (Ŝato)",
"cRed": "Ruĝa (Nuligo)", "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_avatar": "Via nuna profilbildo",
"current_password": "Nuna pasvorto",
"current_profile_banner": "Via nuna profila rubando", "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": "Filtrado",
"filtering_explanation": "Ĉiuj statoj kun tiuj ĉi vortoj silentiĝos, po unu linie", "filtering_explanation": "Ĉiuj statoj kun tiuj ĉi vortoj silentiĝos, po unu linio",
"follow_export": "Abona elporto",
"follow_export_button": "Elporti viajn abonojn al CSV-dosiero",
"follow_export_processing": "Traktante; baldaŭ vi ricevos peton elŝuti la dosieron",
"follow_import": "Abona enporto", "follow_import": "Abona enporto",
"follow_import_error": "Eraro enportante abonojn", "follow_import_error": "Eraro enportante abonojn",
"follows_imported": "Abonoj enportiĝis! Traktado daŭros iom.", "follows_imported": "Abonoj enportiĝis! Traktado daŭros iom.",
"foreground": "Malfono", "foreground": "Malfono",
"general": "Ĝenerala",
"hide_attachments_in_convo": "Kaŝi kunsendaĵojn en interparoloj", "hide_attachments_in_convo": "Kaŝi kunsendaĵojn en interparoloj",
"hide_attachments_in_tl": "Kaŝi kunsendaĵojn en tempolinio", "hide_attachments_in_tl": "Kaŝi kunsendaĵojn en 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_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", "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": "Nomo",
"name_bio": "Nomo kaj priskribo", "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", "nsfw_clickthrough": "Ŝalti traklakan kaŝon de konsternaj kunsendaĵoj",
"panelRadius": "Paneloj", "oauth_tokens": "Ĵetonoj de OAuth",
"token": "Ĵetono",
"refresh_token": "Ĵetono de novigo",
"valid_until": "Valida ĝis",
"revoke_token": "Senvalidigi",
"panelRadius": "Bretoj",
"pause_on_unfocused": "Paŭzigi elsendfluon kiam langeto ne estas fokusata",
"presets": "Antaŭagordoj", "presets": "Antaŭagordoj",
"profile_background": "Profila fono", "profile_background": "Profila fono",
"profile_banner": "Profila rubando", "profile_banner": "Profila rubando",
"radii_help": "Agordi fasadan rondigon de randoj (rastrumere)", "profile_tab": "Profilo",
"reply_link_preview": "Ŝalti respond-ligilan antaŭvidon dum ŝvebo", "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_avatar": "Agordi novan profilbildon",
"set_new_profile_background": "Agordi novan profilan fonon", "set_new_profile_background": "Agordi novan profilan fonon",
"set_new_profile_banner": "Agordi novan profilan rubandon", "set_new_profile_banner": "Agordi novan profilan rubandon",
"settings": "Agordoj", "settings": "Agordoj",
"stop_gifs": "Movi GIF-bildojn dum ŝvebo", "subject_input_always_show": "Ĉiam montri teman kampon",
"subject_line_behavior": "Kopii temon por respondo",
"subject_line_email": "Kiel retpoŝto: \"re: temo\"",
"subject_line_mastodon": "Kiel Mastodon: kopii senŝanĝe",
"subject_line_noop": "Ne kopii",
"post_status_content_type": "Afiŝi specon de la enhavo de la stato",
"stop_gifs": "Movi GIF-bildojn dum musa ŝvebo",
"streaming": "Ŝalti memfaran fluigon de novaj afiŝoj ĉe la supro de la paĝo", "streaming": "Ŝalti memfaran fluigon de novaj afiŝoj ĉe la supro de la paĝo",
"text": "Teksto", "text": "Teksto",
"theme": "Etoso", "theme": "Haŭto",
"theme_help": "Uzu deksesumajn kolorkodojn (#rrvvbb) por adapti vian koloran etoson.", "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", "tooltipRadius": "Ŝpruchelpiloj/avertoj",
"user_settings": "Uzantaj agordoj" "upload_a_photo": "Alŝuti foton",
"user_settings": "Agordoj de uzanto",
"values": {
"false": "ne",
"true": "jes"
},
"notifications": "Sciigoj",
"enable_web_push_notifications": "Ŝalti retajn puŝajn sciigojn",
"style": {
"switcher": {
"keep_color": "Konservi kolorojn",
"keep_shadows": "Konservi ombrojn",
"keep_opacity": "Konservi maltravideblecon",
"keep_roundness": "Konservi rondecon",
"keep_fonts": "Konservi tiparojn",
"save_load_hint": "Elektebloj de \"konservi\" konservas la nuntempajn agordojn dum elektado aŭ enlegado de haŭtoj. Ĝi ankaŭ konservas tiujn agordojn dum elportado de haŭto. Kun ĉiuj markbutonoj nemarkitaj, elporto de la haŭto ĉion konservos.",
"reset": "Restarigi",
"clear_all": "Vakigi ĉion",
"clear_opacity": "Vakigi maltravideblecon"
},
"common": {
"color": "Koloro",
"opacity": "Maltravidebleco",
"contrast": {
"hint": "Proporcio de kontrasto estas {ratio}, ĝi {level} {context}",
"level": {
"aa": "plenumas la gvidilon je nivelo AA (malpleja)",
"aaa": "plenumas la gvidilon je nivela AAA (rekomendita)",
"bad": "plenumas neniujn faciluzajn gvidilojn"
},
"context": {
"18pt": "por granda (18pt+) teksto",
"text": "por teksto"
}
}
},
"common_colors": {
"_tab_label": "Komunaj",
"main": "Komunaj koloroj",
"foreground_hint": "Vidu langeton \"Specialaj\" por pli detalaj agordoj",
"rgbo": "Bildsimboloj, emfazoj, insignoj"
},
"advanced_colors": {
"_tab_label": "Specialaj",
"alert": "Averta fono",
"alert_error": "Eraro",
"badge": "Insigna fono",
"badge_notification": "Sciigo",
"panel_header": "Kapo de breto",
"top_bar": "Supra breto",
"borders": "Limoj",
"buttons": "Butonoj",
"inputs": "Enigaj kampoj",
"faint_text": "Malvigla teksto"
},
"radii": {
"_tab_label": "Rondeco"
},
"shadows": {
"_tab_label": "Ombro kaj lumo",
"component": "Ero",
"override": "Transpasi",
"shadow_id": "Ombro #{value}",
"blur": "Malklarigo",
"spread": "Vastigo",
"inset": "Internigo",
"hint": "Por ombroj vi ankaŭ povas uzi --variable kiel koloran valoron, por uzi variantojn de CSS3. Bonvolu rimarki, ke tiuokaze agordoj de maltravidebleco ne funkcios.",
"filter_hint": {
"always_drop_shadow": "Averto: ĉi tiu ombro ĉiam uzas {0} kiam la foliumilo ĝin subtenas.",
"drop_shadow_syntax": "{0} ne subtenas parametron {1} kaj ŝlosilvorton {2}.",
"avatar_inset": "Bonvolu rimarki, ke agordi ambaŭ internajn kaj eksterajn ombrojn por profilbildoj povas redoni neatenditajn rezultojn ĉe profilbildoj travideblaj.",
"spread_zero": "Ombroj kun vastigo > 0 aperos kvazaŭ ĝi estus fakte nulo",
"inset_classic": "Internaj ombroj uzos {0}"
},
"components": {
"panel": "Breto",
"panelHeader": "Kapo de breto",
"topBar": "Supra breto",
"avatar": "Profilbildo de uzanto (en profila vido)",
"avatarStatus": "Profilbildo de uzanto (en afiŝa vido)",
"popup": "Ŝprucaĵoj",
"button": "Butono",
"buttonHover": "Butono (je ŝvebo)",
"buttonPressed": "Butono (premita)",
"buttonPressedHover": "Butono (premita je ŝvebo)",
"input": "Eniga kampo"
}
},
"fonts": {
"_tab_label": "Tiparoj",
"help": "Elektu tiparon uzotan por eroj de la fasado. Por \"propra\" vi devas enigi la precizan nomon de tiparo tiel, kiel ĝi aperas en la sistemo",
"components": {
"interface": "Fasado",
"input": "Enigaj kampoj",
"post": "Teksto de afiŝo",
"postCode": "Egallarĝa teksto en afiŝo (riĉteksto)"
},
"family": "Nomo de tiparo",
"size": "Grando (en bilderoj)",
"weight": "Pezo (graseco)",
"custom": "Propra"
},
"preview": {
"header": "Antaŭrigardo",
"content": "Enhavo",
"error": "Ekzempla eraro",
"button": "Butono",
"text": "Kelko da pliaj {0} kaj {1}",
"mono": "enhavo",
"input": "Ĵus alvenis al la Universala Kongreso!",
"faint_link": "helpan manlibron",
"fine_print": "Legu nian {0} por nenion utilan ekscii!",
"header_faint": "Tio estas en ordo",
"checkbox": "Mi legetis la kondiĉojn de uzado",
"link": "bela eta ligil"
}
}
}, },
"timeline": { "timeline": {
"collapse": "Maletendi", "collapse": "Maletendi",
"conversation": "Interparolo", "conversation": "Interparolo",
"error_fetching": "Eraro dum ĝisdatigo", "error_fetching": "Eraro dum ĝisdatigo",
"load_older": "Montri pli malnovajn statojn", "load_older": "Montri pli malnovajn statojn",
"repeated": "ripetata", "no_retweet_hint": "Afiŝo estas markita kiel rekta aŭ nur por abonantoj, kaj ne eblas ĝin ripeti",
"repeated": "ripetita",
"show_new": "Montri novajn", "show_new": "Montri novajn",
"up_to_date": "Ĝisdata" "up_to_date": "Ĝisdata",
"no_more_statuses": "Neniuj pliaj statoj",
"no_statuses": "Neniuj statoj"
}, },
"user_card": { "user_card": {
"approve": "Aprobi",
"block": "Bari", "block": "Bari",
"blocked": "Barita!", "blocked": "Barita!",
"deny": "Rifuzi",
"favorites": "Ŝatataj",
"follow": "Aboni", "follow": "Aboni",
"follow_sent": "Peto sendiĝis!",
"follow_progress": "Petanta…",
"follow_again": "Ĉu sendi peton denove?",
"follow_unfollow": "Malaboni",
"followees": "Abonatoj", "followees": "Abonatoj",
"followers": "Abonantoj", "followers": "Abonantoj",
"following": "Abonanta!", "following": "Abonanta!",
"follows_you": "Abonas vin!", "follows_you": "Abonas vin!",
"its_you": "Tio estas vi!",
"media": "Aŭdvidaĵoj",
"mute": "Silentigi", "mute": "Silentigi",
"muted": "Silentigitaj", "muted": "Silentigitaj",
"per_day": "tage", "per_day": "tage",
"remote_follow": "Fore aboni", "remote_follow": "Fore aboni",
"statuses": "Statoj" "statuses": "Statoj",
"unblock": "Malbari",
"unblock_progress": "Malbaranta…",
"block_progress": "Baranta…",
"unmute": "Malsilentigi",
"unmute_progress": "Malsilentiganta…",
"mute_progress": "Silentiganta…"
}, },
"user_profile": { "user_profile": {
"timeline_title": "Uzanta tempolinio" "timeline_title": "Uzanta tempolinio",
"profile_does_not_exist": "Pardonu, ĉi tiu profilo ne ekzistas.",
"profile_loading_error": "Pardonu, eraro okazis dum enlegado de ĉi tiu profilo."
},
"who_to_follow": {
"more": "Pli",
"who_to_follow": "Kiun aboni"
},
"tool_tip": {
"media_upload": "Alŝuti aŭdvidaĵon",
"repeat": "Ripeti",
"reply": "Respondi",
"favorite": "Ŝati",
"user_settings": "Agordoj de uzanto"
},
"upload":{
"error": {
"base": "Alŝuto malsukcesis.",
"file_too_big": "Dosiero estas tro granda [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]",
"default": "Reprovu pli poste"
},
"file_size_units": {
"B": "B",
"KiB": "KiB",
"MiB": "MiB",
"GiB": "GiB",
"TiB": "TiB"
}
} }
} }

View file

@ -61,7 +61,7 @@
"account_not_locked_warning_link": "bloqueada", "account_not_locked_warning_link": "bloqueada",
"attachments_sensitive": "Contenido sensible", "attachments_sensitive": "Contenido sensible",
"content_type": { "content_type": {
"plain_text": "Texto Plano" "text/plain": "Texto Plano"
}, },
"content_warning": "Tema (opcional)", "content_warning": "Tema (opcional)",
"default": "Acabo de aterrizar en L.A.", "default": "Acabo de aterrizar en L.A.",
@ -202,7 +202,6 @@
"subject_line_mastodon": "Tipo mastodon: copiar como es", "subject_line_mastodon": "Tipo mastodon: copiar como es",
"subject_line_noop": "No copiar", "subject_line_noop": "No copiar",
"post_status_content_type": "Formato de publicación", "post_status_content_type": "Formato de publicación",
"status_content_type_plain": "Texto plano",
"stop_gifs": "Iniciar GIFs al pasar el ratón", "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", "streaming": "Habilite la transmisión automática de nuevas publicaciones cuando se desplaza hacia la parte superior",
"text": "Texto", "text": "Texto",

View file

@ -60,7 +60,7 @@
"account_not_locked_warning_link": "lukittu", "account_not_locked_warning_link": "lukittu",
"attachments_sensitive": "Merkkaa liitteet arkaluonteisiksi", "attachments_sensitive": "Merkkaa liitteet arkaluonteisiksi",
"content_type": { "content_type": {
"plain_text": "Tavallinen teksti" "text/plain": "Tavallinen teksti"
}, },
"content_warning": "Aihe (valinnainen)", "content_warning": "Aihe (valinnainen)",
"default": "Tulin juuri saunasta.", "default": "Tulin juuri saunasta.",

View file

@ -51,7 +51,7 @@
"account_not_locked_warning_link": "verrouillé", "account_not_locked_warning_link": "verrouillé",
"attachments_sensitive": "Marquer le média comme sensible", "attachments_sensitive": "Marquer le média comme sensible",
"content_type": { "content_type": {
"plain_text": "Texte brut" "text/plain": "Texte brut"
}, },
"content_warning": "Sujet (optionnel)", "content_warning": "Sujet (optionnel)",
"default": "Écrivez ici votre prochain statut.", "default": "Écrivez ici votre prochain statut.",

View file

@ -49,7 +49,7 @@
"account_not_locked_warning_link": "faoi glas", "account_not_locked_warning_link": "faoi glas",
"attachments_sensitive": "Marcáil ceangaltán mar íogair", "attachments_sensitive": "Marcáil ceangaltán mar íogair",
"content_type": { "content_type": {
"plain_text": "Gnáth-théacs" "text/plain": "Gnáth-théacs"
}, },
"content_warning": "Teideal (roghnach)", "content_warning": "Teideal (roghnach)",
"default": "Lá iontach anseo i nGaillimh", "default": "Lá iontach anseo i nGaillimh",

View file

@ -49,7 +49,7 @@
"account_not_locked_warning_link": "נעול", "account_not_locked_warning_link": "נעול",
"attachments_sensitive": "סמן מסמכים מצורפים כלא בטוחים לצפייה", "attachments_sensitive": "סמן מסמכים מצורפים כלא בטוחים לצפייה",
"content_type": { "content_type": {
"plain_text": "טקסט פשוט" "text/plain": "טקסט פשוט"
}, },
"content_warning": "נושא (נתון לבחירה)", "content_warning": "נושא (נתון לבחירה)",
"default": "הרגע נחת ב-ל.א.", "default": "הרגע נחת ב-ל.א.",

View file

@ -175,7 +175,7 @@
"account_not_locked_warning_link": "bloccato", "account_not_locked_warning_link": "bloccato",
"attachments_sensitive": "Segna allegati come sensibili", "attachments_sensitive": "Segna allegati come sensibili",
"content_type": { "content_type": {
"plain_text": "Testo normale" "text/plain": "Testo normale"
}, },
"content_warning": "Oggetto (facoltativo)", "content_warning": "Oggetto (facoltativo)",
"default": "Appena atterrato in L.A.", "default": "Appena atterrato in L.A.",

View file

@ -61,7 +61,7 @@
"account_not_locked_warning_link": "ロックされたアカウント", "account_not_locked_warning_link": "ロックされたアカウント",
"attachments_sensitive": "ファイルをNSFWにする", "attachments_sensitive": "ファイルをNSFWにする",
"content_type": { "content_type": {
"plain_text": "プレーンテキスト" "text/plain": "プレーンテキスト"
}, },
"content_warning": "せつめい (かかなくてもよい)", "content_warning": "せつめい (かかなくてもよい)",
"default": "はねだくうこうに、つきました。", "default": "はねだくうこうに、つきました。",
@ -202,7 +202,6 @@
"subject_line_mastodon": "マストドンふう: そのままコピー", "subject_line_mastodon": "マストドンふう: そのままコピー",
"subject_line_noop": "コピーしない", "subject_line_noop": "コピーしない",
"post_status_content_type": "とうこうのコンテントタイプ", "post_status_content_type": "とうこうのコンテントタイプ",
"status_content_type_plain": "プレーンテキスト",
"stop_gifs": "カーソルをかさねたとき、GIFをうごかす", "stop_gifs": "カーソルをかさねたとき、GIFをうごかす",
"streaming": "うえまでスクロールしたとき、じどうてきにストリーミングする", "streaming": "うえまでスクロールしたとき、じどうてきにストリーミングする",
"text": "もじ", "text": "もじ",

View file

@ -56,7 +56,7 @@
"account_not_locked_warning_link": "잠김", "account_not_locked_warning_link": "잠김",
"attachments_sensitive": "첨부물을 민감함으로 설정", "attachments_sensitive": "첨부물을 민감함으로 설정",
"content_type": { "content_type": {
"plain_text": "평문" "text/plain": "평문"
}, },
"content_warning": "주제 (필수 아님)", "content_warning": "주제 (필수 아님)",
"default": "LA에 도착!", "default": "LA에 도착!",

View file

@ -10,6 +10,7 @@
const messages = { const messages = {
ar: require('./ar.json'), ar: require('./ar.json'),
ca: require('./ca.json'), ca: require('./ca.json'),
cs: require('./cs.json'),
de: require('./de.json'), de: require('./de.json'),
en: require('./en.json'), en: require('./en.json'),
eo: require('./eo.json'), eo: require('./eo.json'),

View file

@ -49,7 +49,7 @@
"account_not_locked_warning_link": "låst", "account_not_locked_warning_link": "låst",
"attachments_sensitive": "Merk vedlegg som sensitive", "attachments_sensitive": "Merk vedlegg som sensitive",
"content_type": { "content_type": {
"plain_text": "Klar tekst" "text/plain": "Klar tekst"
}, },
"content_warning": "Tema (valgfritt)", "content_warning": "Tema (valgfritt)",
"default": "Landet akkurat i L.A.", "default": "Landet akkurat i L.A.",

View file

@ -57,7 +57,7 @@
"account_not_locked_warning_link": "gesloten", "account_not_locked_warning_link": "gesloten",
"attachments_sensitive": "Markeer bijlage als gevoelig", "attachments_sensitive": "Markeer bijlage als gevoelig",
"content_type": { "content_type": {
"plain_text": "Gewone tekst" "text/plain": "Gewone tekst"
}, },
"content_warning": "Onderwerp (optioneel)", "content_warning": "Onderwerp (optioneel)",
"default": "Tijd voor een pauze!", "default": "Tijd voor een pauze!",

View file

@ -1,51 +1,84 @@
{ {
"chat": { "chat": {
"title": "Messatjariá" "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": { "finder": {
"error_fetching_user": "Error pendent la recèrca dun utilizaire", "error_fetching_user": "Error pendent la cèrca dun utilizaire",
"find_user": "Cercar un utilizaire" "find_user": "Cercar un utilizaire"
}, },
"general": { "general": {
"apply": "Aplicar", "apply": "Aplicar",
"submit": "Mandar" "submit": "Mandar",
"more": "Mai",
"generic_error": "Una error ses producha",
"optional": "opcional"
},
"image_cropper": {
"crop_picture": "Talhar limatge",
"save": "Salvar",
"cancel": "Anullar"
}, },
"login": { "login": {
"login": "Connexion", "login": "Connexion",
"description": "Connexion via OAuth",
"logout": "Desconnexion", "logout": "Desconnexion",
"password": "Senhal", "password": "Senhal",
"placeholder": "e.g. lain", "placeholder": "e.g. lain",
"register": "Se marcar", "register": "Se marcar",
"username": "Nom dutilizaire" "username": "Nom dutilizaire",
"hint": "Connectatz-vos per participar a la discutida"
},
"media_modal": {
"previous": "Precedent",
"next": "Seguent"
}, },
"nav": { "nav": {
"about": "A prepaus",
"back": "Tornar",
"chat": "Chat local", "chat": "Chat local",
"friend_requests": "Demandas de seguiment",
"mentions": "Notificacions", "mentions": "Notificacions",
"dms": "Messatges privats",
"public_tl": "Estatuts locals", "public_tl": "Estatuts locals",
"timeline": "Flux dactualitat", "timeline": "Flux dactualitat",
"twkn": "Lo malhum conegut", "twkn": "Lo malhum conegut",
"friend_requests": "Demandas d'abonament" "user_search": "Cèrca dutilizaires",
"who_to_follow": "Qual seguir",
"preferences": "Preferéncias"
}, },
"notifications": { "notifications": {
"broken_favorite": "Estatut desconegut, sèm a lo cercar...",
"favorited_you": "a aimat vòstre estatut", "favorited_you": "a aimat vòstre estatut",
"followed_you": "vos a seguit", "followed_you": "vos a seguit",
"load_older": "Cargar las notificacions mai ancianas",
"notifications": "Notficacions", "notifications": "Notficacions",
"read": "Legit !", "read": "Legit!",
"repeated_you": "a repetit vòstre estatut", "repeated_you": "a repetit vòstre estatut",
"broken_favorite": "Estatut desconegut, sèm a lo cercar...", "no_more_notifications": "Pas mai de notificacions"
"load_older": "Cargar las notificaciones mai ancianas"
}, },
"post_status": { "post_status": {
"content_warning": "Avís de contengut (opcional)", "new_status": "Publicar destatuts novèls",
"default": "Escrivètz aquí vòstre estatut.", "account_not_locked_warning": "Vòstre compte es pas {0}. Qual que siá pòt vos seguir per veire vòstras publicacions destinadas pas qua vòstres seguidors.",
"posting": "Mandadís",
"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", "account_not_locked_warning_link": "clavat",
"attachments_sensitive": "Marcar las pèças juntas coma sensiblas", "attachments_sensitive": "Marcar las pèças juntas coma sensiblas",
"content_type": { "content_type": {
"plain_text": "Tèxte brut" "text/plain": "Tèxte brut",
"text/html": "HTML",
"text/markdown": "Markdown"
}, },
"content_warning": "Avís de contengut (opcional)",
"default": "Escrivètz aquí vòstre estatut.",
"direct_warning": "Aquesta publicacion serà pas que visibla pels utilizaires mencionats.", "direct_warning": "Aquesta publicacion serà pas que visibla pels utilizaires mencionats.",
"posting": "Mandadís",
"scope": { "scope": {
"direct": "Dirècte - Publicar pels utilizaires mencionats solament", "direct": "Dirècte - Publicar pels utilizaires mencionats solament",
"private": "Seguidors solament - Publicar pels sols seguidors", "private": "Seguidors solament - Publicar pels sols seguidors",
@ -59,9 +92,23 @@
"fullname": "Nom complèt", "fullname": "Nom complèt",
"password_confirm": "Confirmar lo senhal", "password_confirm": "Confirmar lo senhal",
"registration": "Inscripcion", "registration": "Inscripcion",
"token": "Geton de convidat" "token": "Geton de convidat",
"captcha": "CAPTCHA",
"new_captcha": "Clicatz limatge 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 danimes 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": { "settings": {
"app_name": "Nom de laplicacion",
"attachmentRadius": "Pèças juntas", "attachmentRadius": "Pèças juntas",
"attachments": "Pèças juntas", "attachments": "Pèças juntas",
"autoload": "Activar lo cargament automatic un còp arribat al cap de la pagina", "autoload": "Activar lo cargament automatic un còp arribat al cap de la pagina",
@ -70,23 +117,30 @@
"avatarRadius": "Avatars", "avatarRadius": "Avatars",
"background": "Rèire plan", "background": "Rèire plan",
"bio": "Biografia", "bio": "Biografia",
"blocks_tab": "Blocatges",
"btnRadius": "Botons", "btnRadius": "Botons",
"cBlue": "Blau (Respondre, seguir)", "cBlue": "Blau (Respondre, seguir)",
"cGreen": "Verd (Repartajar)", "cGreen": "Verd (Repertir)",
"cOrange": "Irange (Aimar)", "cOrange": "Irange (Aimar)",
"cRed": "Roge (Anullar)", "cRed": "Roge (Anullar)",
"change_password": "Cambiar lo senhal", "change_password": "Cambiar lo senhal",
"change_password_error": "Una error ses producha en cambiant lo senhal.", "change_password_error": "Una error ses producha en cambiant lo senhal.",
"changed_password": "Senhal corrèctament cambiat !", "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", "confirm_new_password": "Confirmatz lo nòu senhal",
"current_avatar": "Vòstre avatar actual", "current_avatar": "Vòstre avatar actual",
"current_password": "Senhal actual", "current_password": "Senhal actual",
"current_profile_banner": "Bandièra actuala del perfil", "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": "Suprimir lo compte",
"delete_account_description": "Suprimir vòstre compte e los messatges per sempre.", "delete_account_description": "Suprimir vòstre compte e los messatges per sempre.",
"delete_account_error": "Una error ses producha en suprimir lo compte. Saquò ten darribar mercés de contactar vòstre administrador dinstància.", "delete_account_error": "Una error ses producha en suprimir lo compte. Saquò ten darribar mercés de contactar vòstre administrator dinstància.",
"delete_account_instructions": "Picatz vòstre senhal dins lo camp tèxte çai-jos per confirmar la supression del compte.", "delete_account_instructions": "Picatz vòstre senhal dins lo camp tèxte çai-jos per confirmar la supression del compte.",
"filtering": "Filtre", "avatar_size_instruction": "La talha minimum recomandada pels imatges davatar 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", "filtering_explanation": "Totes los estatuts amb aqueles mots seràn en silenci, un mot per linha",
"follow_export": "Exportar los abonaments", "follow_export": "Exportar los abonaments",
"follow_export_button": "Exportar vòstres abonaments dins un fichièr csv", "follow_export_button": "Exportar vòstres abonaments dins un fichièr csv",
@ -95,66 +149,204 @@
"follow_import_error": "Error en important los seguidors", "follow_import_error": "Error en important los seguidors",
"follows_imported": "Seguidors importats. Lo tractament pòt trigar una estona.", "follows_imported": "Seguidors importats. Lo tractament pòt trigar una estona.",
"foreground": "Endavant", "foreground": "Endavant",
"general": "General",
"hide_attachments_in_convo": "Rescondre las pèças juntas dins las conversacions", "hide_attachments_in_convo": "Rescondre las pèças juntas dins las conversacions",
"hide_attachments_in_tl": "Rescondre las pèças juntas", "hide_attachments_in_tl": "Rescondre las pèças juntas",
"import_followers_from_a_csv_file": "Importar los seguidors dun fichièr csv", "max_thumbnails": "Nombre maximum de vinhetas per publicacion",
"inputRadius": "Camps tèxte", "hide_isp": "Amagar lo panèl especial instància",
"links": "Ligams", "preload_images": "Precargar los imatges",
"name": "Nom", "use_one_click_nsfw": "Dobrir las pèças juntas NSFW amb un clic",
"name_bio": "Nom & Bio", "hide_post_stats": "Amagar las estatisticas de publicacion (ex. lo nombre de favorits)",
"new_password": "Nòu senhal",
"nsfw_clickthrough": "Activar lo clic per mostrar los imatges marcats coma pels adults o sensibles",
"panelRadius": "Panèls",
"presets": "Pre-enregistrats",
"profile_background": "Imatge de fons",
"profile_banner": "Bandièra del perfil",
"radii_help": "Configurar los caires arredondits de linterfàcia (en pixèls)",
"reply_link_preview": "Activar lapercebut en passar la mirga",
"set_new_avatar": "Cambiar lavatar",
"set_new_profile_background": "Cambiar limatge de fons",
"set_new_profile_banner": "Cambiar de bandièra",
"settings": "Paramètres",
"stop_gifs": "Lançar los GIFs al subrevòl",
"streaming": "Activar lo cargament automatic dels novèls estatus en anar amont",
"text": "Tèxte",
"theme": "Tèma",
"theme_help": "Emplegatz los còdis de color hex (#rrggbb) per personalizar vòstre tèma de color.",
"tooltipRadius": "Astúcias/Alèrta",
"user_settings": "Paramètres utilizaire",
"collapse_subject": "Replegar las publicacions amb de subjèctes",
"data_import_export_tab": "Importar / Exportar las donadas",
"default_vis": "Nivèl de visibilitat per defaut",
"export_theme": "Enregistrar la preconfiguracion",
"general": "General",
"hide_post_stats": "Amagar los estatistics de publicacion (ex. lo ombre de favorits)",
"hide_user_stats": "Amagar las estatisticas de lutilizaire (ex. lo nombre de seguidors)", "hide_user_stats": "Amagar las estatisticas de lutilizaire (ex. lo nombre de seguidors)",
"hide_filtered_statuses": "Amagar los estatuts filtrats",
"import_followers_from_a_csv_file": "Importar los seguidors dun fichièr csv",
"import_theme": "Cargar un tèma", "import_theme": "Cargar un tèma",
"instance_default": "(defaut : {value})", "inputRadius": "Camps tèxte",
"checkboxRadius": "Casas de marcar",
"instance_default": "(defaut: {value})",
"instance_default_simple": "(defaut)",
"interface": "Interfàcia",
"interfaceLanguage": "Lenga de linterfàcia", "interfaceLanguage": "Lenga de linterfà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.", "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", "limited_availability": "Pas disponible per vòstre navigador",
"links": "Ligams",
"lock_account_description": "Limitar vòstre compte als seguidors acceptats solament", "lock_account_description": "Limitar vòstre compte als seguidors acceptats solament",
"loop_video": "Bocla vidèo", "loop_video": "Bocla vidèo",
"loop_video_silent_only": "Legir en bocla solament las vidèos sens son (coma los « Gifs » de Mastodon)", "loop_video_silent_only": "Legir en bocla solament las vidèos sens son (coma los « Gifs » de Mastodon)",
"notification_visibility": "Tipes de notificacion de mostrar", "mutes_tab": "Agamats",
"play_videos_in_modal": "Legir las vidèos 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_follows": "Abonaments",
"notification_visibility_likes": "Aiman", "notification_visibility_likes": "Aimar",
"notification_visibility_mentions": "Mencions", "notification_visibility_mentions": "Mencions",
"notification_visibility_repeats": "Repeticions", "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_rich_text_description": "Netejar lo format tèxte de totas las publicacions",
"oauth_tokens": "Llistats OAuth", "no_blocks": "Cap de blocatge",
"no_mutes": "Cap damagat",
"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 longlet es pas seleccionat", "pause_on_unfocused": "Pausar la difusion quand longlet es pas seleccionat",
"presets": "Pre-enregistrats",
"profile_background": "Imatge de fons",
"profile_banner": "Bandièra del perfil",
"profile_tab": "Perfil", "profile_tab": "Perfil",
"radii_help": "Configurar los caires arredondits de linterfàcia (en pixèls)",
"replies_in_timeline": "Responsas del flux", "replies_in_timeline": "Responsas del flux",
"reply_link_preview": "Activar lapercebut en passar la mirga",
"reply_visibility_all": "Mostrar totas las responsas", "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_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", "reply_visibility_self": "Mostrar pas que las responsas que me son destinadas",
"saving_err": "Error en enregistrant los paramètres", "saving_err": "Error en enregistrant los paramètres",
"saving_ok": "Paramètres enregistrats", "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", "security_tab": "Seguretat",
"set_new_avatar": "Definir un nòu avatar",
"set_new_profile_background": "Definir un nòu fons de perfil",
"set_new_profile_banner": "Definir una nòva bandièra de perfil",
"settings": "Paramètres",
"subject_input_always_show": "Totjorn mostrar lo camp de subjècte",
"subject_line_behavior": "Copiar lo subjècte per las responsas",
"subject_line_email": "Coma los corrièls: \"re: subjècte\"",
"subject_line_mastodon": "Coma mastodon: copiar tal coma es",
"subject_line_noop": "Copiar pas",
"post_status_content_type": "Publicar lo tipe de contengut dels estatuts",
"stop_gifs": "Lançar los GIFs al subrevòl",
"streaming": "Activar lo cargament automatic dels novèls estatus en anar amont",
"text": "Tèxte",
"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": { "values": {
"false": "non", "false": "non",
"true": "òc" "true": "òc"
},
"notifications": "Notificacions",
"enable_web_push_notifications": "Activar las notificacions web push",
"style": {
"switcher": {
"keep_color": "Gardar las colors",
"keep_shadows": "Gardar las ombras",
"keep_opacity": "Gardar lopacitat",
"keep_roundness": "Gardar la redondetat",
"keep_fonts": "Gardar las polissas",
"save_load_hint": "Las opcions « Gardar » permeton de servar las opcions configuradas actualament quand seleccionatz o cargatz un tèma, permeton tanben denregistrar aquelas opcions quand exportatz un tèma. Quand totas las casas son pas marcadas, lexportacion de tèma o enregistrarà tot.",
"reset": "Restablir",
"clear_all": "O escafar tot",
"clear_opacity": "Escafar lopacitat"
},
"common": {
"color": "Color",
"opacity": "Opacitat",
"contrast": {
"hint": "Lo coeficient de contraste es de {ratio}. Dòna {level} {context}",
"level": {
"aa": "un nivèl AA minimum recomandat",
"aaa": "un nivèl AAA recomandat",
"bad": "pas un nivèl daccessibilitat recomandat"
},
"context": {
"18pt": "pel tèxte grand (18pt+)",
"text": "pel tèxte"
}
}
},
"common_colors": {
"_tab_label": "Comun",
"main": "Colors comunas",
"foreground_hint": "Vejatz « Avançat » per mai de paramètres detalhats",
"rgbo": "Icònas, accents, badges"
},
"advanced_colors": {
"_tab_label": "Avançat",
"alert": "Rèire plan dalèrtas",
"alert_error": "Error",
"badge": "Rèire plan dels badges",
"badge_notification": "Notificacion",
"panel_header": "Bandièra del tablèu de bòrd",
"top_bar": "Barra amont",
"borders": "Caires",
"buttons": "Botons",
"inputs": "Camps tèxte",
"faint_text": "Tèxte descolorit"
},
"radii": {
"_tab_label": "Redondetat"
},
"shadows": {
"_tab_label": "Ombra e luminositat",
"component": "Compausant",
"override": "Subrecargar",
"shadow_id": "Ombra #{value}",
"blur": "Fosc",
"spread": "Espandiment",
"inset": "Incrustacion",
"hint": "Per las ombras podètz tanben utilizar --variable coma valor de color per emplegar una variable CSS3. Notatz que lo paramètre dopacitat foncionarà pas dins aquel cas.",
"filter_hint": {
"always_drop_shadow": "Avertiment, aquel ombra utiliza totjorn {0} quand lo navigator es compatible.",
"drop_shadow_syntax": "{0} es pas compatible amb lo paramètre {1} e lo mot clau {2}.",
"avatar_inset": "Notatz que combinar dombras incrustadas e pas incrustadas pòt donar de resultats inesperats amb los avatars transparents.",
"spread_zero": "Lombra amb un espandiment de > 0 apareisserà coma reglat a zèro",
"inset_classic": "Lombra dincrustacion utilizarà {0}"
},
"components": {
"panel": "Tablèu",
"panelHeader": "Bandièra del tablèu",
"topBar": "Barra amont",
"avatar": "Utilizar lavatar (vista perfil)",
"avatarStatus": "Avatar de lutilizaire (afichatge publicacion)",
"popup": "Fenèstras sorgissentas e astúcias",
"button": "Boton",
"buttonHover": "Boton (en passar la mirga)",
"buttonPressed": "Boton (en quichar)",
"buttonPressedHover": "Boton (en quichar e passar)",
"input": "Camp tèxte"
}
},
"fonts": {
"_tab_label": "Polissas",
"help": "Selecionatz la polissa dutilizar pels elements de lUI. Per « Personalizada » vos cal picar lo nom exacte tal coma apareis sul sistèma.",
"components": {
"interface": "Interfàcia",
"input": "Camps tèxte",
"post": "Tèxte de publicacion",
"postCode": "Tèxte Monospaced dins las publicacion (tèxte formatat)"
},
"family": "Nom de la polissa",
"size": "Talha (en px)",
"weight": "Largor (gras)",
"custom": "Personalizada"
},
"preview": {
"header": "Apercebut",
"content": "Contengut",
"error": "Error dexemple",
"button": "Boton",
"text": "A tròç de mai de {0} e {1}",
"mono": "contengut",
"input": "arribada al país.",
"faint_link": "manual dajuda",
"fine_print": "Legissètz nòstre {0} per legir pas res dutil!",
"header_faint": "Va plan",
"checkbox": "Ai legit los tèrmes e condicions dutilizacion",
"link": "un pichon ligam simpatic"
}
} }
}, },
"timeline": { "timeline": {
@ -162,41 +354,74 @@
"conversation": "Conversacion", "conversation": "Conversacion",
"error_fetching": "Error en cercant de mesas a jorn", "error_fetching": "Error en cercant de mesas a jorn",
"load_older": "Ne veire mai", "load_older": "Ne veire mai",
"no_retweet_hint": "Las publicacions marcadas pels seguidors solament o dirèctas se pòdon pas repetir",
"repeated": "repetit", "repeated": "repetit",
"show_new": "Ne veire mai", "show_new": "Ne veire mai",
"up_to_date": "A jorn", "up_to_date": "A jorn",
"no_retweet_hint": "La publicacion marcada coma pels seguidors solament o dirècte pòt pas èsser repetida" "no_more_statuses": "Pas mai destatuts",
"no_statuses": "Cap destatuts"
},
"status": {
"reply_to": "Respond a",
"replies_list": "Responsas:"
}, },
"user_card": { "user_card": {
"approve": "Validar",
"block": "Blocar", "block": "Blocar",
"blocked": "Blocat !", "blocked": "Blocat!",
"deny": "Refusar",
"favorites": "Favorits",
"follow": "Seguir", "follow": "Seguir",
"follow_sent": "Demanda enviada!",
"follow_progress": "Demanda…",
"follow_again": "Tornar enviar la demanda?",
"follow_unfollow": "Quitar de seguir",
"followees": "Abonaments", "followees": "Abonaments",
"followers": "Seguidors", "followers": "Seguidors",
"following": "Seguit !", "following": "Seguit!",
"follows_you": "Vos sèc !", "follows_you": "Vos sèc!",
"its_you": "Sètz vos!",
"media": "Mèdia",
"mute": "Amagar", "mute": "Amagar",
"muted": "Amagat", "muted": "Amagat",
"per_day": "per jorn", "per_day": "per jorn",
"remote_follow": "Seguir a distància", "remote_follow": "Seguir a distància",
"statuses": "Estatuts", "statuses": "Estatuts",
"approve": "Validar", "unblock": "Desblocar",
"deny": "Refusar" "unblock_progress": "Desblocatge...",
"block_progress": "Blocatge...",
"unmute": "Tornar mostrar",
"unmute_progress": "Afichatge...",
"mute_progress": "A amagar..."
}, },
"user_profile": { "user_profile": {
"timeline_title": "Flux utilizaire" "timeline_title": "Flux utilizaire",
}, "profile_does_not_exist": "Aqueste perfil existís pas.",
"features_panel": { "profile_loading_error": "Una error ses producha en cargant aqueste perfil."
"chat": "Discutida",
"gopher": "Gopher",
"media_proxy": "Servidor mandatari dels mèdias",
"scope_options": "Opcions d'encastres",
"text_limit": "Limit de tèxte",
"title": "Foncionalitats",
"who_to_follow": "Qui seguir"
}, },
"who_to_follow": { "who_to_follow": {
"more": "Mai", "more": "Mai",
"who_to_follow": "Qui seguir" "who_to_follow": "Qual seguir"
},
"tool_tip": {
"media_upload": "Enviar un mèdia",
"repeat": "Repetir",
"reply": "Respondre",
"favorite": "aimar",
"user_settings": "Paramètres utilizaire"
},
"upload":{
"error": {
"base": "Mandadís fracassat.",
"file_too_big": "Fichièr tròp grand [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]",
"default": "Tornatz ensajar mai tard"
},
"file_size_units": {
"B": "o",
"KiB": "Kio",
"MiB": "Mio",
"GiB": "Gio",
"TiB": "Tio"
}
} }
} }

View file

@ -2,116 +2,424 @@
"chat": { "chat": {
"title": "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": { "finder": {
"error_fetching_user": "Erro procurando usuário", "error_fetching_user": "Erro ao procurar usuário",
"find_user": "Buscar usuário" "find_user": "Buscar usuário"
}, },
"general": { "general": {
"apply": "Aplicar", "apply": "Aplicar",
"submit": "Enviar" "submit": "Enviar",
"more": "Mais",
"generic_error": "Houve um erro",
"optional": "opcional"
},
"image_cropper": {
"crop_picture": "Cortar imagem",
"save": "Salvar",
"cancel": "Cancelar"
}, },
"login": { "login": {
"login": "Entrar", "login": "Entrar",
"description": "Entrar com OAuth",
"logout": "Sair", "logout": "Sair",
"password": "Senha", "password": "Senha",
"placeholder": "p.e. lain", "placeholder": "p.e. lain",
"register": "Registrar", "register": "Registrar",
"username": "Usuário" "username": "Usuário",
"hint": "Entre para participar da discussão"
},
"media_modal": {
"previous": "Anterior",
"next": "Próximo"
}, },
"nav": { "nav": {
"about": "Sobre",
"back": "Voltar",
"chat": "Chat local", "chat": "Chat local",
"friend_requests": "Solicitações de seguidores",
"mentions": "Menções", "mentions": "Menções",
"dms": "Mensagens diretas",
"public_tl": "Linha do tempo pública", "public_tl": "Linha do tempo pública",
"timeline": "Linha do tempo", "timeline": "Linha do tempo",
"twkn": "Toda a rede conhecida" "twkn": "Toda a rede conhecida",
"user_search": "Buscar usuários",
"who_to_follow": "Quem seguir",
"preferences": "Preferências"
}, },
"notifications": { "notifications": {
"broken_favorite": "Status desconhecido, buscando...",
"favorited_you": "favoritou sua postagem", "favorited_you": "favoritou sua postagem",
"followed_you": "seguiu você", "followed_you": "seguiu você",
"load_older": "Carregar notificações antigas",
"notifications": "Notificações", "notifications": "Notificações",
"read": "Lido!", "read": "Lido!",
"repeated_you": "repetiu sua postagem" "repeated_you": "repetiu sua postagem",
"no_more_notifications": "Mais nenhuma notificação"
}, },
"post_status": { "post_status": {
"new_status": "Postar novo status",
"account_not_locked_warning": "Sua conta não é {0}. Qualquer pessoa pode te seguir e ver seus posts privados (só para seguidores).",
"account_not_locked_warning_link": "restrita",
"attachments_sensitive": "Marcar anexos como sensíveis",
"content_type": {
"text/plain": "Texto puro"
},
"content_warning": "Assunto (opcional)",
"default": "Acabei de chegar no Rio!", "default": "Acabei de chegar no Rio!",
"posting": "Publicando" "direct_warning": "Este post será visível apenas para os usuários mencionados.",
"posting": "Publicando",
"scope": {
"direct": "Direto - Enviar somente aos usuários mencionados",
"private": "Apenas para seguidores - Enviar apenas para seguidores",
"public": "Público - Enviar a linhas do tempo públicas",
"unlisted": "Não listado - Não enviar a linhas do tempo públicas"
}
}, },
"registration": { "registration": {
"bio": "Biografia", "bio": "Biografia",
"email": "Correio eletrônico", "email": "Correio eletrônico",
"fullname": "Nome para exibição", "fullname": "Nome para exibição",
"password_confirm": "Confirmação de senha", "password_confirm": "Confirmação de senha",
"registration": "Registro" "registration": "Registro",
"token": "Código do convite",
"captcha": "CAPTCHA",
"new_captcha": "Clique na imagem para carregar um novo captcha",
"username_placeholder": "p. ex. lain",
"fullname_placeholder": "p. ex. Lain Iwakura",
"bio_placeholder": "e.g.\nOi, sou Lain\nSou uma garota que vive no subúrbio do Japão. Você deve me conhecer da Rede.",
"validations": {
"username_required": "não pode ser deixado em branco",
"fullname_required": "não pode ser deixado em branco",
"email_required": "não pode ser deixado em branco",
"password_required": "não pode ser deixado em branco",
"password_confirmation_required": "não pode ser deixado em branco",
"password_confirmation_match": "deve ser idêntica à senha"
}
}, },
"settings": { "settings": {
"app_name": "Nome do aplicativo",
"attachmentRadius": "Anexos", "attachmentRadius": "Anexos",
"attachments": "Anexos", "attachments": "Anexos",
"autoload": "Habilitar carregamento automático quando a rolagem chegar ao fim.", "autoload": "Habilitar carregamento automático quando a rolagem chegar ao fim.",
"avatar": "Avatar", "avatar": "Avatar",
"avatarAltRadius": "Avatares (Notificações)", "avatarAltRadius": "Avatares (Notificações)",
"avatarRadius": "Avatares", "avatarRadius": "Avatares",
"background": "Plano de Fundo", "background": "Pano de Fundo",
"bio": "Biografia", "bio": "Biografia",
"blocks_tab": "Bloqueios",
"btnRadius": "Botões", "btnRadius": "Botões",
"cBlue": "Azul (Responder, seguir)", "cBlue": "Azul (Responder, seguir)",
"cGreen": "Verde (Repetir)", "cGreen": "Verde (Repetir)",
"cOrange": "Laranja (Favoritar)", "cOrange": "Laranja (Favoritar)",
"cRed": "Vermelho (Cancelar)", "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": "Escrita",
"confirm_new_password": "Confirmar nova senha",
"current_avatar": "Seu avatar atual", "current_avatar": "Seu avatar atual",
"current_password": "Sua senha atual",
"current_profile_banner": "Sua capa de perfil 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": "Filtragem",
"filtering_explanation": "Todas as postagens contendo estas palavras serão silenciadas, uma por linha.", "filtering_explanation": "Todas as postagens contendo estas palavras serão silenciadas; uma palavra por linha.",
"follow_import": "Importar seguidas", "follow_export": "Exportar quem você segue",
"follow_export_button": "Exportar quem você segue para um arquivo CSV",
"follow_export_processing": "Processando. Em breve você receberá a solicitação de download do arquivo",
"follow_import": "Importar quem você segue",
"follow_import_error": "Erro ao importar seguidores", "follow_import_error": "Erro ao importar seguidores",
"follows_imported": "Seguidores importados! O processamento pode demorar um pouco.", "follows_imported": "Seguidores importados! O processamento pode demorar um pouco.",
"foreground": "Primeiro Plano", "foreground": "Primeiro Plano",
"general": "Geral",
"hide_attachments_in_convo": "Ocultar anexos em conversas", "hide_attachments_in_convo": "Ocultar anexos em conversas",
"hide_attachments_in_tl": "Ocultar anexos na linha do tempo.", "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_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", "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": "Nome",
"name_bio": "Nome & Biografia", "name_bio": "Nome & Biografia",
"nsfw_clickthrough": "Habilitar clique para ocultar anexos NSFW", "new_password": "Nova senha",
"notification_visibility": "Tipos de notificação para mostrar",
"notification_visibility_follows": "Seguidas",
"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 título 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", "panelRadius": "Paineis",
"pause_on_unfocused": "Parar transmissão quando a aba não estiver em primeiro plano",
"presets": "Predefinições", "presets": "Predefinições",
"profile_background": "Plano de fundo de perfil", "profile_background": "Pano de fundo de perfil",
"profile_banner": "Capa de perfil", "profile_banner": "Capa de perfil",
"radii_help": "Arredondar arestas da interface (em píxeis)", "profile_tab": "Perfil",
"reply_link_preview": "Habilitar a pré-visualização de link de respostas ao passar o mouse.", "radii_help": "Arredondar arestas da interface (em pixel)",
"replies_in_timeline": "Respostas na linha do tempo",
"reply_link_preview": "Habilitar a pré-visualização de 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_avatar": "Alterar avatar",
"set_new_profile_background": "Alterar o plano de fundo de perfil", "set_new_profile_background": "Alterar o pano de fundo de perfil",
"set_new_profile_banner": "Alterar capa de perfil", "set_new_profile_banner": "Alterar capa de perfil",
"settings": "Configurações", "settings": "Configurações",
"stop_gifs": "Reproduzir GIFs ao passar o cursor em cima", "subject_input_always_show": "Sempre mostrar campo de assunto",
"streaming": "Habilitar o fluxo automático de postagens quando ao topo da página", "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": "Tipo de conteúdo do status",
"stop_gifs": "Reproduzir GIFs ao passar o cursor",
"streaming": "Habilitar o fluxo automático de postagens no topo da página",
"text": "Texto", "text": "Texto",
"theme": "Tema", "theme": "Tema",
"theme_help": "Use cores em código hexadecimal (#rrggbb) para personalizar seu esquema de cores.", "theme_help": "Use cores em código hexadecimal (#rrggbb) para personalizar seu esquema de cores.",
"tooltipRadius": "Dicass/alertas", "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.",
"user_settings": "Configurações de Usuário" "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": "Notificações",
"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": "Restaurar o 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": "Selecione as fontes dos elementos da interface. Para fonte \"personalizada\" você deve inserir o mesmo 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": { "timeline": {
"collapse": "Esconder",
"conversation": "Conversa", "conversation": "Conversa",
"error_fetching": "Erro buscando atualizações", "error_fetching": "Erro ao buscar atualizações",
"load_older": "Carregar postagens antigas", "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", "show_new": "Mostrar novas",
"up_to_date": "Atualizado" "up_to_date": "Atualizado",
"no_more_statuses": "Sem mais posts",
"no_statuses": "Sem posts"
},
"status": {
"reply_to": "Responder a",
"replies_list": "Respostas:"
}, },
"user_card": { "user_card": {
"approve": "Aprovar",
"block": "Bloquear", "block": "Bloquear",
"blocked": "Bloqueado!", "blocked": "Bloqueado!",
"deny": "Negar",
"favorites": "Favoritos",
"follow": "Seguir", "follow": "Seguir",
"follow_sent": "Pedido enviado!",
"follow_progress": "Enviando…",
"follow_again": "Enviar solicitação novamente?",
"follow_unfollow": "Deixar de seguir",
"followees": "Seguindo", "followees": "Seguindo",
"followers": "Seguidores", "followers": "Seguidores",
"following": "Seguindo!", "following": "Seguindo!",
"follows_you": "Segue você!", "follows_you": "Segue você!",
"its_you": "É você!",
"media": "Mídia",
"mute": "Silenciar", "mute": "Silenciar",
"muted": "Silenciado", "muted": "Silenciado",
"per_day": "por dia", "per_day": "por dia",
"remote_follow": "Seguidor Remoto", "remote_follow": "Seguir remotamente",
"statuses": "Postagens" "statuses": "Postagens",
"unblock": "Desbloquear",
"unblock_progress": "Desbloqueando...",
"block_progress": "Bloqueando...",
"unmute": "Retirar silêncio",
"unmute_progress": "Retirando silêncio...",
"mute_progress": "Silenciando..."
}, },
"user_profile": { "user_profile": {
"timeline_title": "Linha do tempo do usuário" "timeline_title": "Linha do tempo do usuário",
"profile_does_not_exist": "Desculpe, este perfil não existe.",
"profile_loading_error": "Desculpe, houve um erro ao carregar este perfil."
},
"who_to_follow": {
"more": "Mais",
"who_to_follow": "Quem seguir"
},
"tool_tip": {
"media_upload": "Envio de mídia",
"repeat": "Repetir",
"reply": "Responder",
"favorite": "Favoritar",
"user_settings": "Configurações do usuário"
},
"upload":{
"error": {
"base": "Falha no envio.",
"file_too_big": "Arquivo grande demais [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]",
"default": "Tente novamente mais tarde"
},
"file_size_units": {
"B": "B",
"KiB": "KiB",
"MiB": "MiB",
"GiB": "GiB",
"TiB": "TiB"
}
} }
} }

View file

@ -49,7 +49,7 @@
"account_not_locked_warning_link": "上锁", "account_not_locked_warning_link": "上锁",
"attachments_sensitive": "标记附件为敏感内容", "attachments_sensitive": "标记附件为敏感内容",
"content_type": { "content_type": {
"plain_text": "纯文本" "text/plain": "纯文本"
}, },
"content_warning": "主题(可选)", "content_warning": "主题(可选)",
"default": "刚刚抵达上海", "default": "刚刚抵达上海",

View file

@ -60,18 +60,6 @@ export default function createPersistedState ({
merge({}, store.state, savedState) merge({}, store.state, savedState)
) )
} }
if (store.state.config.customTheme) {
// This is a hack to deal with async loading of config.json and themes
// See: style_setter.js, setPreset()
window.themeLoaded = true
store.dispatch('setOption', {
name: 'customTheme',
value: store.state.config.customTheme
})
}
if (store.state.oauth.token) {
store.dispatch('loginUser', store.state.oauth.token)
}
loaded = true loaded = true
} catch (e) { } catch (e) {
console.log("Couldn't load state") console.log("Couldn't load state")

View file

@ -30,8 +30,9 @@ const currentLocale = (window.navigator.language || 'en').split('-')[0]
Vue.use(Vuex) Vue.use(Vuex)
Vue.use(VueRouter) Vue.use(VueRouter)
Vue.use(VueTimeago, { Vue.use(VueTimeago, {
locale: currentLocale === 'ja' ? 'ja' : 'en', locale: currentLocale === 'cs' ? 'cs' : currentLocale === 'ja' ? 'ja' : 'en',
locales: { locales: {
'cs': require('../static/timeago-cs.json'),
'en': require('../static/timeago-en.json'), 'en': require('../static/timeago-en.json'),
'ja': require('../static/timeago-ja.json') 'ja': require('../static/timeago-ja.json')
} }
@ -52,9 +53,10 @@ const persistedStateOptions = {
'users.lastLoginName', 'users.lastLoginName',
'oauth' 'oauth'
] ]
} };
createPersistedState(persistedStateOptions).then((persistedState) => { (async () => {
const persistedState = await createPersistedState(persistedStateOptions)
const store = new Vuex.Store({ const store = new Vuex.Store({
modules: { modules: {
interface: interfaceModule, interface: interfaceModule,
@ -74,7 +76,7 @@ createPersistedState(persistedStateOptions).then((persistedState) => {
}) })
afterStoreSetup({ store, i18n }) afterStoreSetup({ store, i18n })
}) })()
// These are inlined by webpack's DefinePlugin // These are inlined by webpack's DefinePlugin
/* eslint-disable */ /* eslint-disable */

View file

@ -1,12 +1,16 @@
const chat = { const chat = {
state: { state: {
messages: [], messages: [],
channel: {state: ''} channel: {state: ''},
socket: null
}, },
mutations: { mutations: {
setChannel (state, channel) { setChannel (state, channel) {
state.channel = channel state.channel = channel
}, },
setSocket (state, socket) {
state.socket = socket
},
addMessage (state, message) { addMessage (state, message) {
state.messages.push(message) state.messages.push(message)
state.messages = state.messages.slice(-19, 20) state.messages = state.messages.slice(-19, 20)
@ -16,8 +20,12 @@ const chat = {
} }
}, },
actions: { actions: {
disconnectFromChat (store) {
store.state.socket.disconnect()
},
initializeChat (store, socket) { initializeChat (store, socket) {
const channel = socket.channel('chat:public') const channel = socket.channel('chat:public')
store.commit('setSocket', socket)
channel.on('new_msg', (msg) => { channel.on('new_msg', (msg) => {
store.commit('addMessage', msg) store.commit('addMessage', msg)
}) })

View file

@ -5,6 +5,7 @@ const browserLocale = (window.navigator.language || 'en').split('-')[0]
const defaultState = { const defaultState = {
colors: {}, colors: {},
hideMutedPosts: undefined, // instance default
collapseMessageWithSubject: undefined, // instance default collapseMessageWithSubject: undefined, // instance default
hideAttachments: false, hideAttachments: false,
hideAttachmentsInConv: false, hideAttachmentsInConv: false,

View file

@ -17,6 +17,7 @@ const defaultState = {
showInstanceSpecificPanel: false, showInstanceSpecificPanel: false,
formattingOptionsEnabled: false, formattingOptionsEnabled: false,
alwaysShowSubjectInput: true, alwaysShowSubjectInput: true,
hideMutedPosts: false,
collapseMessageWithSubject: false, collapseMessageWithSubject: false,
hidePostStats: false, hidePostStats: false,
hideUserStats: false, hideUserStats: false,
@ -37,6 +38,7 @@ const defaultState = {
emoji: [], emoji: [],
customEmoji: [], customEmoji: [],
restrictedNicknames: [], restrictedNicknames: [],
postFormats: [],
// Feature-set, apparently, not everything here is reported... // Feature-set, apparently, not everything here is reported...
mediaProxyAvailable: false, mediaProxyAvailable: false,
@ -47,7 +49,11 @@ const defaultState = {
// Html stuff // Html stuff
instanceSpecificPanelContent: '', instanceSpecificPanelContent: '',
tos: '' tos: '',
// Version Information
backendVersion: '',
frontendVersion: ''
} }
const instance = { const instance = {

View file

@ -1,4 +1,5 @@
import { remove, slice, each, find, maxBy, minBy, merge, last, isArray } from 'lodash' import { remove, slice, each, find, maxBy, minBy, merge, first, last, isArray, omitBy } from 'lodash'
import { set } from 'vue'
import apiService from '../services/api/api.service.js' import apiService from '../services/api/api.service.js'
// import parse from '../services/status_parser/status_parser.js' // import parse from '../services/status_parser/status_parser.js'
@ -10,6 +11,7 @@ const emptyTl = (userId = 0) => ({
visibleStatusesObject: {}, visibleStatusesObject: {},
newStatusCount: 0, newStatusCount: 0,
maxId: 0, maxId: 0,
minId: 0,
minVisibleId: 0, minVisibleId: 0,
loading: false, loading: false,
followers: [], followers: [],
@ -18,7 +20,7 @@ const emptyTl = (userId = 0) => ({
flushMarker: 0 flushMarker: 0
}) })
export const defaultState = { export const defaultState = () => ({
allStatuses: [], allStatuses: [],
allStatusesObject: {}, allStatusesObject: {},
maxId: 0, maxId: 0,
@ -29,7 +31,8 @@ export const defaultState = {
data: [], data: [],
idStore: {}, idStore: {},
loading: false, loading: false,
error: false error: false,
fetcherId: null
}, },
favorites: new Set(), favorites: new Set(),
error: false, error: false,
@ -44,7 +47,7 @@ export const defaultState = {
tag: emptyTl(), tag: emptyTl(),
dms: emptyTl() dms: emptyTl()
} }
} })
export const prepareStatus = (status) => { export const prepareStatus = (status) => {
// Set deleted flag // Set deleted flag
@ -70,7 +73,9 @@ const mergeOrAdd = (arr, obj, item) => {
if (oldItem) { if (oldItem) {
// We already have this, so only merge the new info. // We already have this, so only merge the new info.
merge(oldItem, item) // We ignore null values to avoid overwriting existing properties with missing data
// we also skip 'user' because that is handled by users module
merge(oldItem, omitBy(item, (v, k) => v === null || k === 'user'))
// Reactivity fix. // Reactivity fix.
oldItem.attachments.splice(oldItem.attachments.length) oldItem.attachments.splice(oldItem.attachments.length)
return {item: oldItem, new: false} return {item: oldItem, new: false}
@ -78,7 +83,7 @@ const mergeOrAdd = (arr, obj, item) => {
// This is a new item, prepare it // This is a new item, prepare it
prepareStatus(item) prepareStatus(item)
arr.push(item) arr.push(item)
obj[item.id] = item set(obj, item.id, item)
return {item, new: true} return {item, new: true}
} }
} }
@ -117,11 +122,16 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us
const timelineObject = state.timelines[timeline] const timelineObject = state.timelines[timeline]
const maxNew = statuses.length > 0 ? maxBy(statuses, 'id').id : 0 const maxNew = statuses.length > 0 ? maxBy(statuses, 'id').id : 0
const older = timeline && maxNew < timelineObject.maxId const minNew = statuses.length > 0 ? minBy(statuses, 'id').id : 0
const newer = timeline && maxNew > timelineObject.maxId && statuses.length > 0
const older = timeline && (minNew < timelineObject.minId || timelineObject.minId === 0) && statuses.length > 0
if (timeline && !noIdUpdate && statuses.length > 0 && !older) { if (!noIdUpdate && newer) {
timelineObject.maxId = maxNew timelineObject.maxId = maxNew
} }
if (!noIdUpdate && older) {
timelineObject.minId = minNew
}
// This makes sure that user timeline won't get data meant for other // This makes sure that user timeline won't get data meant for other
// user. I.e. opening different user profiles makes request which could // user. I.e. opening different user profiles makes request which could
@ -255,12 +265,9 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us
processor(status) processor(status)
}) })
// Keep the visible statuses sorted // Keep the visible statuses sorted
if (timeline) { if (timeline) {
sortTimeline(timelineObject) sortTimeline(timelineObject)
if ((older || timelineObject.minVisibleId <= 0) && statuses.length > 0) {
timelineObject.minVisibleId = minBy(statuses, 'id').id
}
} }
} }
@ -309,18 +316,39 @@ const addNewNotifications = (state, { dispatch, notifications, older, visibleNot
}) })
} }
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 = { export const mutations = {
addNewStatuses, addNewStatuses,
addNewNotifications, addNewNotifications,
removeStatus,
showNewStatuses (state, { timeline }) { showNewStatuses (state, { timeline }) {
const oldTimeline = (state.timelines[timeline]) const oldTimeline = (state.timelines[timeline])
oldTimeline.newStatusCount = 0 oldTimeline.newStatusCount = 0
oldTimeline.visibleStatuses = slice(oldTimeline.statuses, 0, 50) oldTimeline.visibleStatuses = slice(oldTimeline.statuses, 0, 50)
oldTimeline.minVisibleId = last(oldTimeline.visibleStatuses).id oldTimeline.minVisibleId = last(oldTimeline.visibleStatuses).id
oldTimeline.minId = oldTimeline.minVisibleId
oldTimeline.visibleStatusesObject = {} oldTimeline.visibleStatusesObject = {}
each(oldTimeline.visibleStatuses, (status) => { oldTimeline.visibleStatusesObject[status.id] = status }) 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 }) { clearTimeline (state, { timeline }) {
state.timelines[timeline] = emptyTl(state.timelines[timeline].userId) state.timelines[timeline] = emptyTl(state.timelines[timeline].userId)
}, },
@ -335,6 +363,15 @@ export const mutations = {
}, },
setRetweeted (state, { status, value }) { setRetweeted (state, { status, value }) {
const newStatus = state.allStatusesObject[status.id] const newStatus = state.allStatusesObject[status.id]
if (newStatus.repeated !== value) {
if (value) {
newStatus.repeat_num++
} else {
newStatus.repeat_num--
}
}
newStatus.repeated = value newStatus.repeated = value
}, },
setDeleted (state, { status }) { setDeleted (state, { status }) {
@ -371,7 +408,7 @@ export const mutations = {
} }
const statuses = { const statuses = {
state: defaultState, state: defaultState(),
actions: { actions: {
addNewStatuses ({ rootState, commit }, { statuses, showImmediately = false, timeline = false, noIdUpdate = false, userId }) { addNewStatuses ({ rootState, commit }, { statuses, showImmediately = false, timeline = false, noIdUpdate = false, userId }) {
commit('addNewStatuses', { statuses, showImmediately, timeline, noIdUpdate, user: rootState.users.currentUser, userId }) commit('addNewStatuses', { statuses, showImmediately, timeline, noIdUpdate, user: rootState.users.currentUser, userId })
@ -391,6 +428,12 @@ const statuses = {
setNotificationsSilence ({ rootState, commit }, { value }) { setNotificationsSilence ({ rootState, commit }, { value }) {
commit('setNotificationsSilence', { 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) { deleteStatus ({ rootState, commit }, status) {
commit('setDeleted', { status }) commit('setDeleted', { status })
apiService.deleteStatus({ id: status.id, credentials: rootState.users.currentUser.credentials }) apiService.deleteStatus({ id: status.id, credentials: rootState.users.currentUser.credentials })
@ -399,13 +442,6 @@ const statuses = {
// Optimistic favoriting... // Optimistic favoriting...
commit('setFavorited', { status, value: true }) commit('setFavorited', { status, value: true })
apiService.favorite({ id: status.id, credentials: rootState.users.currentUser.credentials }) apiService.favorite({ id: status.id, credentials: rootState.users.currentUser.credentials })
.then(response => {
if (response.ok) {
return response.json()
} else {
return {}
}
})
.then(status => { .then(status => {
commit('setFavoritedConfirm', { status }) commit('setFavoritedConfirm', { status })
}) })
@ -414,13 +450,6 @@ const statuses = {
// Optimistic favoriting... // Optimistic favoriting...
commit('setFavorited', { status, value: false }) commit('setFavorited', { status, value: false })
apiService.unfavorite({ id: status.id, credentials: rootState.users.currentUser.credentials }) apiService.unfavorite({ id: status.id, credentials: rootState.users.currentUser.credentials })
.then(response => {
if (response.ok) {
return response.json()
} else {
return {}
}
})
.then(status => { .then(status => {
commit('setFavoritedConfirm', { status }) commit('setFavoritedConfirm', { status })
}) })

View file

@ -1,5 +1,5 @@
import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js' import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
import { compact, map, each, merge, find } from 'lodash' import { compact, map, each, merge, find, last } from 'lodash'
import { set } from 'vue' import { set } from 'vue'
import { registerPushNotifications, unregisterPushNotifications } from '../services/push/push.js' import { registerPushNotifications, unregisterPushNotifications } from '../services/push/push.js'
import oauthApi from '../services/new_api/oauth' import oauthApi from '../services/new_api/oauth'
@ -16,9 +16,9 @@ export const mergeOrAdd = (arr, obj, item) => {
} else { } else {
// This is a new item, prepare it // This is a new item, prepare it
arr.push(item) arr.push(item)
obj[item.id] = item set(obj, item.id, item)
if (item.screen_name && !item.screen_name.includes('@')) { if (item.screen_name && !item.screen_name.includes('@')) {
obj[item.screen_name] = item set(obj, item.screen_name.toLowerCase(), item)
} }
return { item, new: true } return { item, new: true }
} }
@ -52,23 +52,23 @@ export const mutations = {
state.loggingIn = false state.loggingIn = false
}, },
// TODO Clean after ourselves? // TODO Clean after ourselves?
addFriends (state, { id, friends, page }) { addFriends (state, { id, friends }) {
const user = state.usersObject[id] const user = state.usersObject[id]
each(friends, friend => { each(friends, friend => {
if (!find(user.friends, { id: friend.id })) { if (!find(user.friends, { id: friend.id })) {
user.friends.push(friend) user.friends.push(friend)
} }
}) })
user.friendsPage = page + 1 user.lastFriendId = last(friends).id
}, },
addFollowers (state, { id, followers, page }) { addFollowers (state, { id, followers }) {
const user = state.usersObject[id] const user = state.usersObject[id]
each(followers, follower => { each(followers, follower => {
if (!find(user.followers, { id: follower.id })) { if (!find(user.followers, { id: follower.id })) {
user.followers.push(follower) user.followers.push(follower)
} }
}) })
user.followersPage = page + 1 user.lastFollowerId = last(followers).id
}, },
// Because frontend doesn't have a reason to keep these stuff in memory // Because frontend doesn't have a reason to keep these stuff in memory
// outside of viewing someones user profile. // outside of viewing someones user profile.
@ -78,7 +78,7 @@ export const mutations = {
return return
} }
user.friends = [] user.friends = []
user.friendsPage = 0 user.lastFriendId = null
}, },
clearFollowers (state, userId) { clearFollowers (state, userId) {
const user = state.usersObject[userId] const user = state.usersObject[userId]
@ -86,15 +86,36 @@ export const mutations = {
return return
} }
user.followers = [] user.followers = []
user.followersPage = 0 user.lastFollowerId = null
}, },
addNewUsers (state, users) { addNewUsers (state, users) {
each(users, (user) => mergeOrAdd(state.users, state.usersObject, user)) each(users, (user) => mergeOrAdd(state.users, state.usersObject, user))
}, },
saveBlocks (state, blockIds) { updateUserRelationship (state, relationships) {
relationships.forEach((relationship) => {
const user = state.usersObject[relationship.id]
if (user) {
user.follows_you = relationship.followed_by
user.following = relationship.following
user.muted = relationship.muting
user.statusnet_blocking = relationship.blocking
}
})
},
updateBlocks (state, blockedUsers) {
// Reset statusnet_blocking of all fetched users
each(state.users, (user) => { user.statusnet_blocking = false })
each(blockedUsers, (user) => mergeOrAdd(state.users, state.usersObject, user))
},
saveBlockIds (state, blockIds) {
state.currentUser.blockIds = blockIds state.currentUser.blockIds = blockIds
}, },
saveMutes (state, muteIds) { updateMutes (state, mutedUsers) {
// Reset muted of all fetched users
each(state.users, (user) => { user.muted = false })
each(mutedUsers, (user) => mergeOrAdd(state.users, state.usersObject, user))
},
saveMuteIds (state, muteIds) {
state.currentUser.muteIds = muteIds state.currentUser.muteIds = muteIds
}, },
setUserForStatus (state, status) { setUserForStatus (state, status) {
@ -122,12 +143,14 @@ export const mutations = {
} }
export const getters = { export const getters = {
userById: state => id => findUser: state => query => {
state.users.find(user => user.id === id), const result = state.usersObject[query]
userByName: state => name => // In case it's a screen_name, we can try searching case-insensitive
state.users.find(user => user.screen_name && if (!result && typeof query === 'string') {
(user.screen_name.toLowerCase() === name.toLowerCase()) return state.usersObject[query.toLowerCase()]
) }
return result
}
} }
export const defaultState = { export const defaultState = {
@ -147,47 +170,59 @@ const users = {
actions: { actions: {
fetchUser (store, id) { fetchUser (store, id) {
return store.rootState.api.backendInteractor.fetchUser({ id }) return store.rootState.api.backendInteractor.fetchUser({ id })
.then((user) => store.commit('addNewUsers', [user])) .then((user) => {
store.commit('addNewUsers', [user])
return user
})
},
fetchUserRelationship (store, id) {
return store.rootState.api.backendInteractor.fetchUserRelationship({ id })
.then((relationships) => store.commit('updateUserRelationship', relationships))
}, },
fetchBlocks (store) { fetchBlocks (store) {
return store.rootState.api.backendInteractor.fetchBlocks() return store.rootState.api.backendInteractor.fetchBlocks()
.then((blocks) => { .then((blocks) => {
store.commit('saveBlocks', map(blocks, 'id')) store.commit('saveBlockIds', map(blocks, 'id'))
store.commit('addNewUsers', blocks) store.commit('updateBlocks', blocks)
return blocks return blocks
}) })
}, },
blockUser (store, id) { blockUser (store, userId) {
return store.rootState.api.backendInteractor.blockUser(id) return store.rootState.api.backendInteractor.blockUser(userId)
.then((user) => store.commit('addNewUsers', [user])) .then((relationship) => {
store.commit('updateUserRelationship', [relationship])
store.commit('removeStatus', { timeline: 'friends', userId })
store.commit('removeStatus', { timeline: 'public', userId })
store.commit('removeStatus', { timeline: 'publicAndExternal', userId })
})
}, },
unblockUser (store, id) { unblockUser (store, id) {
return store.rootState.api.backendInteractor.unblockUser(id) return store.rootState.api.backendInteractor.unblockUser(id)
.then((user) => store.commit('addNewUsers', [user])) .then((relationship) => store.commit('updateUserRelationship', [relationship]))
}, },
fetchMutes (store) { fetchMutes (store) {
return store.rootState.api.backendInteractor.fetchMutes() return store.rootState.api.backendInteractor.fetchMutes()
.then((mutedUsers) => { .then((mutes) => {
each(mutedUsers, (user) => { user.muted = true }) store.commit('updateMutes', mutes)
store.commit('addNewUsers', mutedUsers) store.commit('saveMuteIds', map(mutes, 'id'))
store.commit('saveMutes', map(mutedUsers, 'id')) return mutes
}) })
}, },
muteUser (store, id) { muteUser (store, id) {
return store.state.api.backendInteractor.setUserMute({ id, muted: true }) return store.rootState.api.backendInteractor.muteUser(id)
.then((user) => store.commit('addNewUsers', [user])) .then((relationship) => store.commit('updateUserRelationship', [relationship]))
}, },
unmuteUser (store, id) { unmuteUser (store, id) {
return store.state.api.backendInteractor.setUserMute({ id, muted: false }) return store.rootState.api.backendInteractor.unmuteUser(id)
.then((user) => store.commit('addNewUsers', [user])) .then((relationship) => store.commit('updateUserRelationship', [relationship]))
}, },
addFriends ({ rootState, commit }, fetchBy) { addFriends ({ rootState, commit }, fetchBy) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const user = rootState.users.usersObject[fetchBy] const user = rootState.users.usersObject[fetchBy]
const page = user.friendsPage || 1 const maxId = user.lastFriendId
rootState.api.backendInteractor.fetchFriends({ id: user.id, page }) rootState.api.backendInteractor.fetchFriends({ id: user.id, maxId })
.then((friends) => { .then((friends) => {
commit('addFriends', { id: user.id, friends, page }) commit('addFriends', { id: user.id, friends })
resolve(friends) resolve(friends)
}).catch(() => { }).catch(() => {
reject() reject()
@ -196,10 +231,10 @@ const users = {
}, },
addFollowers ({ rootState, commit }, fetchBy) { addFollowers ({ rootState, commit }, fetchBy) {
const user = rootState.users.usersObject[fetchBy] const user = rootState.users.usersObject[fetchBy]
const page = user.followersPage || 1 const maxId = user.lastFollowerId
return rootState.api.backendInteractor.fetchFollowers({ id: user.id, page }) return rootState.api.backendInteractor.fetchFollowers({ id: user.id, maxId })
.then((followers) => { .then((followers) => {
commit('addFollowers', { id: user.id, followers, page }) commit('addFollowers', { id: user.id, followers })
return followers return followers
}) })
}, },
@ -292,9 +327,12 @@ const users = {
logout (store) { logout (store) {
store.commit('clearCurrentUser') store.commit('clearCurrentUser')
store.dispatch('disconnectFromChat')
store.commit('setToken', false) store.commit('setToken', false)
store.dispatch('stopFetching', 'friends') store.dispatch('stopFetching', 'friends')
store.commit('setBackendInteractor', backendInteractorService()) store.commit('setBackendInteractor', backendInteractorService())
store.dispatch('stopFetchingNotifications')
store.commit('resetStatuses')
}, },
loginUser (store, accessToken) { loginUser (store, accessToken) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -319,6 +357,9 @@ const users = {
if (user.token) { if (user.token) {
store.dispatch('setWsToken', user.token) store.dispatch('setWsToken', user.token)
// Initialize the chat socket.
store.dispatch('initializeSocket')
} }
// Start getting fresh posts. // Start getting fresh posts.

View file

@ -1,39 +1,15 @@
/* eslint-env browser */ /* eslint-env browser */
const LOGIN_URL = '/api/account/verify_credentials.json' const LOGIN_URL = '/api/account/verify_credentials.json'
const FRIENDS_TIMELINE_URL = '/api/statuses/friends_timeline.json'
const ALL_FOLLOWING_URL = '/api/qvitter/allfollowing' const ALL_FOLLOWING_URL = '/api/qvitter/allfollowing'
const PUBLIC_TIMELINE_URL = '/api/statuses/public_timeline.json'
const PUBLIC_AND_EXTERNAL_TIMELINE_URL = '/api/statuses/public_and_external_timeline.json'
const TAG_TIMELINE_URL = '/api/statusnet/tags/timeline'
const FAVORITE_URL = '/api/favorites/create'
const UNFAVORITE_URL = '/api/favorites/destroy'
const RETWEET_URL = '/api/statuses/retweet'
const UNRETWEET_URL = '/api/statuses/unretweet'
const STATUS_UPDATE_URL = '/api/statuses/update.json'
const STATUS_DELETE_URL = '/api/statuses/destroy'
const STATUS_URL = '/api/statuses/show'
const MEDIA_UPLOAD_URL = '/api/statusnet/media/upload'
const CONVERSATION_URL = '/api/statusnet/conversation'
const MENTIONS_URL = '/api/statuses/mentions.json' const MENTIONS_URL = '/api/statuses/mentions.json'
const DM_TIMELINE_URL = '/api/statuses/dm_timeline.json'
const FOLLOWERS_URL = '/api/statuses/followers.json'
const FRIENDS_URL = '/api/statuses/friends.json'
const BLOCKS_URL = '/api/statuses/blocks.json'
const FOLLOWING_URL = '/api/friendships/create.json'
const UNFOLLOWING_URL = '/api/friendships/destroy.json'
const QVITTER_USER_PREF_URL = '/api/qvitter/set_profile_pref.json'
const REGISTRATION_URL = '/api/account/register.json' const REGISTRATION_URL = '/api/account/register.json'
const AVATAR_UPDATE_URL = '/api/qvitter/update_avatar.json' const AVATAR_UPDATE_URL = '/api/qvitter/update_avatar.json'
const BG_UPDATE_URL = '/api/qvitter/update_background_image.json' const BG_UPDATE_URL = '/api/qvitter/update_background_image.json'
const BANNER_UPDATE_URL = '/api/account/update_profile_banner.json' const BANNER_UPDATE_URL = '/api/account/update_profile_banner.json'
const PROFILE_UPDATE_URL = '/api/account/update_profile.json' const PROFILE_UPDATE_URL = '/api/account/update_profile.json'
const EXTERNAL_PROFILE_URL = '/api/externalprofile/show.json' const EXTERNAL_PROFILE_URL = '/api/externalprofile/show.json'
const QVITTER_USER_TIMELINE_URL = '/api/qvitter/statuses/user_timeline.json'
const QVITTER_USER_NOTIFICATIONS_URL = '/api/qvitter/statuses/notifications.json' const QVITTER_USER_NOTIFICATIONS_URL = '/api/qvitter/statuses/notifications.json'
const QVITTER_USER_NOTIFICATIONS_READ_URL = '/api/qvitter/statuses/notifications/read.json' const QVITTER_USER_NOTIFICATIONS_READ_URL = '/api/qvitter/statuses/notifications/read.json'
const BLOCKING_URL = '/api/blocks/create.json'
const UNBLOCKING_URL = '/api/blocks/destroy.json'
const USER_URL = '/api/users/show.json'
const FOLLOW_IMPORT_URL = '/api/pleroma/follow_import' const FOLLOW_IMPORT_URL = '/api/pleroma/follow_import'
const DELETE_ACCOUNT_URL = '/api/pleroma/delete_account' const DELETE_ACCOUNT_URL = '/api/pleroma/delete_account'
const CHANGE_PASSWORD_URL = '/api/pleroma/change_password' const CHANGE_PASSWORD_URL = '/api/pleroma/change_password'
@ -43,9 +19,35 @@ const DENY_USER_URL = '/api/pleroma/friendships/deny'
const SUGGESTIONS_URL = '/api/v1/suggestions' const SUGGESTIONS_URL = '/api/v1/suggestions'
const MASTODON_USER_FAVORITES_TIMELINE_URL = '/api/v1/favourites' const MASTODON_USER_FAVORITES_TIMELINE_URL = '/api/v1/favourites'
const MASTODON_FAVORITE_URL = id => `/api/v1/statuses/${id}/favourite`
const MASTODON_UNFAVORITE_URL = id => `/api/v1/statuses/${id}/unfavourite`
const MASTODON_RETWEET_URL = id => `/api/v1/statuses/${id}/reblog`
const MASTODON_UNRETWEET_URL = id => `/api/v1/statuses/${id}/unreblog`
const MASTODON_DELETE_URL = id => `/api/v1/statuses/${id}`
const MASTODON_FOLLOW_URL = id => `/api/v1/accounts/${id}/follow`
const MASTODON_UNFOLLOW_URL = id => `/api/v1/accounts/${id}/unfollow`
const MASTODON_FOLLOWING_URL = id => `/api/v1/accounts/${id}/following`
const MASTODON_FOLLOWERS_URL = id => `/api/v1/accounts/${id}/followers`
const MASTODON_DIRECT_MESSAGES_TIMELINE_URL = '/api/v1/timelines/direct'
const MASTODON_PUBLIC_TIMELINE = '/api/v1/timelines/public'
const MASTODON_USER_HOME_TIMELINE_URL = '/api/v1/timelines/home'
const MASTODON_STATUS_URL = id => `/api/v1/statuses/${id}`
const MASTODON_STATUS_CONTEXT_URL = id => `/api/v1/statuses/${id}/context`
const MASTODON_USER_URL = '/api/v1/accounts'
const MASTODON_USER_RELATIONSHIPS_URL = '/api/v1/accounts/relationships'
const MASTODON_USER_TIMELINE_URL = id => `/api/v1/accounts/${id}/statuses`
const MASTODON_TAG_TIMELINE_URL = tag => `/api/v1/timelines/tag/${tag}`
const MASTODON_USER_BLOCKS_URL = '/api/v1/blocks/'
const MASTODON_USER_MUTES_URL = '/api/v1/mutes/'
const MASTODON_BLOCK_USER_URL = id => `/api/v1/accounts/${id}/block`
const MASTODON_UNBLOCK_USER_URL = id => `/api/v1/accounts/${id}/unblock`
const MASTODON_MUTE_USER_URL = id => `/api/v1/accounts/${id}/mute`
const MASTODON_UNMUTE_USER_URL = id => `/api/v1/accounts/${id}/unmute`
const MASTODON_POST_STATUS_URL = '/api/v1/statuses'
const MASTODON_MEDIA_UPLOAD_URL = '/api/v1/media'
import { each, map } from 'lodash' import { each, map } from 'lodash'
import { parseStatus, parseUser, parseNotification } from '../entity_normalizer/entity_normalizer.service.js' import { parseStatus, parseUser, parseNotification, parseAttachment } from '../entity_normalizer/entity_normalizer.service.js'
import 'whatwg-fetch' import 'whatwg-fetch'
import { StatusCodeError } from '../errors/errors' import { StatusCodeError } from '../errors/errors'
@ -59,6 +61,19 @@ let fetch = (url, options) => {
return oldfetch(fullUrl, options) return oldfetch(fullUrl, options)
} }
const promisedRequest = (url, options) => {
return fetch(url, options)
.then((response) => {
return new Promise((resolve, reject) => response.json()
.then((json) => {
if (!response.ok) {
return reject(new StatusCodeError(response.status, json, { url, options }, response))
}
return resolve(json)
}))
})
}
// Params // Params
// cropH // cropH
// cropW // cropW
@ -195,7 +210,7 @@ const externalProfile = ({profileUrl, credentials}) => {
} }
const followUser = ({id, credentials}) => { const followUser = ({id, credentials}) => {
let url = `${FOLLOWING_URL}?user_id=${id}` let url = MASTODON_FOLLOW_URL(id)
return fetch(url, { return fetch(url, {
headers: authHeaders(credentials), headers: authHeaders(credentials),
method: 'POST' method: 'POST'
@ -203,7 +218,7 @@ const followUser = ({id, credentials}) => {
} }
const unfollowUser = ({id, credentials}) => { const unfollowUser = ({id, credentials}) => {
let url = `${UNFOLLOWING_URL}?user_id=${id}` let url = MASTODON_UNFOLLOW_URL(id)
return fetch(url, { return fetch(url, {
headers: authHeaders(credentials), headers: authHeaders(credentials),
method: 'POST' method: 'POST'
@ -211,16 +226,14 @@ const unfollowUser = ({id, credentials}) => {
} }
const blockUser = ({id, credentials}) => { const blockUser = ({id, credentials}) => {
let url = `${BLOCKING_URL}?user_id=${id}` return fetch(MASTODON_BLOCK_USER_URL(id), {
return fetch(url, {
headers: authHeaders(credentials), headers: authHeaders(credentials),
method: 'POST' method: 'POST'
}).then((data) => data.json()) }).then((data) => data.json())
} }
const unblockUser = ({id, credentials}) => { const unblockUser = ({id, credentials}) => {
let url = `${UNBLOCKING_URL}?user_id=${id}` return fetch(MASTODON_UNBLOCK_USER_URL(id), {
return fetch(url, {
headers: authHeaders(credentials), headers: authHeaders(credentials),
method: 'POST' method: 'POST'
}).then((data) => data.json()) }).then((data) => data.json())
@ -243,7 +256,13 @@ const denyUser = ({id, credentials}) => {
} }
const fetchUser = ({id, credentials}) => { const fetchUser = ({id, credentials}) => {
let url = `${USER_URL}?user_id=${id}` let url = `${MASTODON_USER_URL}/${id}`
return promisedRequest(url, { headers: authHeaders(credentials) })
.then((data) => parseUser(data))
}
const fetchUserRelationship = ({id, credentials}) => {
let url = `${MASTODON_USER_RELATIONSHIPS_URL}/?id=${id}`
return fetch(url, { headers: authHeaders(credentials) }) return fetch(url, { headers: authHeaders(credentials) })
.then((response) => { .then((response) => {
return new Promise((resolve, reject) => response.json() return new Promise((resolve, reject) => response.json()
@ -254,31 +273,38 @@ const fetchUser = ({id, credentials}) => {
return resolve(json) return resolve(json)
})) }))
}) })
.then((data) => parseUser(data))
} }
const fetchFriends = ({id, page, credentials}) => { const fetchFriends = ({id, maxId, sinceId, limit = 20, credentials}) => {
let url = `${FRIENDS_URL}?user_id=${id}` let url = MASTODON_FOLLOWING_URL(id)
if (page) { const args = [
url = url + `&page=${page}` maxId && `max_id=${maxId}`,
} sinceId && `since_id=${sinceId}`,
limit && `limit=${limit}`
].filter(_ => _).join('&')
url = url + (args ? '?' + args : '')
return fetch(url, { headers: authHeaders(credentials) }) return fetch(url, { headers: authHeaders(credentials) })
.then((data) => data.json()) .then((data) => data.json())
.then((data) => data.map(parseUser)) .then((data) => data.map(parseUser))
} }
const exportFriends = ({id, credentials}) => { const exportFriends = ({id, credentials}) => {
let url = `${FRIENDS_URL}?user_id=${id}&all=true` let url = MASTODON_FOLLOWING_URL(id) + `?all=true`
return fetch(url, { headers: authHeaders(credentials) }) return fetch(url, { headers: authHeaders(credentials) })
.then((data) => data.json()) .then((data) => data.json())
.then((data) => data.map(parseUser)) .then((data) => data.map(parseUser))
} }
const fetchFollowers = ({id, page, credentials}) => { const fetchFollowers = ({id, maxId, sinceId, limit = 20, credentials}) => {
let url = `${FOLLOWERS_URL}?user_id=${id}` let url = MASTODON_FOLLOWERS_URL(id)
if (page) { const args = [
url = url + `&page=${page}` maxId && `max_id=${maxId}`,
} sinceId && `since_id=${sinceId}`,
limit && `limit=${limit}`
].filter(_ => _).join('&')
url += args ? '?' + args : ''
return fetch(url, { headers: authHeaders(credentials) }) return fetch(url, { headers: authHeaders(credentials) })
.then((data) => data.json()) .then((data) => data.json())
.then((data) => data.map(parseUser)) .then((data) => data.map(parseUser))
@ -298,8 +324,8 @@ const fetchFollowRequests = ({credentials}) => {
} }
const fetchConversation = ({id, credentials}) => { const fetchConversation = ({id, credentials}) => {
let url = `${CONVERSATION_URL}/${id}.json?count=100` let urlContext = MASTODON_STATUS_CONTEXT_URL(id)
return fetch(url, { headers: authHeaders(credentials) }) return fetch(urlContext, { headers: authHeaders(credentials) })
.then((data) => { .then((data) => {
if (data.ok) { if (data.ok) {
return data return data
@ -307,11 +333,14 @@ const fetchConversation = ({id, credentials}) => {
throw new Error('Error fetching timeline', data) throw new Error('Error fetching timeline', data)
}) })
.then((data) => data.json()) .then((data) => data.json())
.then((data) => data.map(parseStatus)) .then(({ancestors, descendants}) => ({
ancestors: ancestors.map(parseStatus),
descendants: descendants.map(parseStatus)
}))
} }
const fetchStatus = ({id, credentials}) => { const fetchStatus = ({id, credentials}) => {
let url = `${STATUS_URL}/${id}.json` let url = MASTODON_STATUS_URL(id)
return fetch(url, { headers: authHeaders(credentials) }) return fetch(url, { headers: authHeaders(credentials) })
.then((data) => { .then((data) => {
if (data.ok) { if (data.ok) {
@ -323,57 +352,49 @@ const fetchStatus = ({id, credentials}) => {
.then((data) => parseStatus(data)) .then((data) => parseStatus(data))
} }
const setUserMute = ({id, credentials, muted = true}) => { const fetchTimeline = ({timeline, credentials, since = false, until = false, userId = false, tag = false, withMuted = false}) => {
const form = new FormData()
const muteInteger = muted ? 1 : 0
form.append('namespace', 'qvitter')
form.append('data', muteInteger)
form.append('topic', `mute:${id}`)
return fetch(QVITTER_USER_PREF_URL, {
method: 'POST',
headers: authHeaders(credentials),
body: form
})
}
const fetchTimeline = ({timeline, credentials, since = false, until = false, userId = false, tag = false}) => {
const timelineUrls = { const timelineUrls = {
public: PUBLIC_TIMELINE_URL, public: MASTODON_PUBLIC_TIMELINE,
friends: FRIENDS_TIMELINE_URL, friends: MASTODON_USER_HOME_TIMELINE_URL,
mentions: MENTIONS_URL, mentions: MENTIONS_URL,
dms: DM_TIMELINE_URL, dms: MASTODON_DIRECT_MESSAGES_TIMELINE_URL,
notifications: QVITTER_USER_NOTIFICATIONS_URL, notifications: QVITTER_USER_NOTIFICATIONS_URL,
'publicAndExternal': PUBLIC_AND_EXTERNAL_TIMELINE_URL, 'publicAndExternal': MASTODON_PUBLIC_TIMELINE,
user: QVITTER_USER_TIMELINE_URL, user: MASTODON_USER_TIMELINE_URL,
media: QVITTER_USER_TIMELINE_URL, media: MASTODON_USER_TIMELINE_URL,
favorites: MASTODON_USER_FAVORITES_TIMELINE_URL, favorites: MASTODON_USER_FAVORITES_TIMELINE_URL,
tag: TAG_TIMELINE_URL tag: MASTODON_TAG_TIMELINE_URL
} }
const isNotifications = timeline === 'notifications' const isNotifications = timeline === 'notifications'
const params = [] const params = []
let url = timelineUrls[timeline] let url = timelineUrls[timeline]
if (timeline === 'user' || timeline === 'media') {
url = url(userId)
}
if (since) { if (since) {
params.push(['since_id', since]) params.push(['since_id', since])
} }
if (until) { if (until) {
params.push(['max_id', until]) params.push(['max_id', until])
} }
if (userId) {
params.push(['user_id', userId])
}
if (tag) { if (tag) {
url += `/${tag}.json` url = url(tag)
} }
if (timeline === 'media') { if (timeline === 'media') {
params.push(['only_media', 1]) params.push(['only_media', 1])
} }
if (timeline === 'public') {
params.push(['local', true])
}
if (timeline === 'public' || timeline === 'publicAndExternal') {
params.push(['only_media', false])
}
params.push(['count', 20]) params.push(['count', 20])
params.push(['with_muted', withMuted])
const queryString = map(params, (param) => `${param[0]}=${param[1]}`).join('&') const queryString = map(params, (param) => `${param[0]}=${param[1]}`).join('&')
url += `?${queryString}` url += `?${queryString}`
@ -407,50 +428,82 @@ const verifyCredentials = (user) => {
} }
const favorite = ({ id, credentials }) => { const favorite = ({ id, credentials }) => {
return fetch(`${FAVORITE_URL}/${id}.json`, { return fetch(MASTODON_FAVORITE_URL(id), {
headers: authHeaders(credentials), headers: authHeaders(credentials),
method: 'POST' method: 'POST'
}) })
.then(response => {
if (response.ok) {
return response.json()
} else {
throw new Error('Error favoriting post')
}
})
.then((data) => parseStatus(data))
} }
const unfavorite = ({ id, credentials }) => { const unfavorite = ({ id, credentials }) => {
return fetch(`${UNFAVORITE_URL}/${id}.json`, { return fetch(MASTODON_UNFAVORITE_URL(id), {
headers: authHeaders(credentials), headers: authHeaders(credentials),
method: 'POST' method: 'POST'
}) })
.then(response => {
if (response.ok) {
return response.json()
} else {
throw new Error('Error removing favorite')
}
})
.then((data) => parseStatus(data))
} }
const retweet = ({ id, credentials }) => { const retweet = ({ id, credentials }) => {
return fetch(`${RETWEET_URL}/${id}.json`, { return fetch(MASTODON_RETWEET_URL(id), {
headers: authHeaders(credentials), headers: authHeaders(credentials),
method: 'POST' method: 'POST'
}) })
.then(response => {
if (response.ok) {
return response.json()
} else {
throw new Error('Error repeating post')
}
})
.then((data) => parseStatus(data))
} }
const unretweet = ({ id, credentials }) => { const unretweet = ({ id, credentials }) => {
return fetch(`${UNRETWEET_URL}/${id}.json`, { return fetch(MASTODON_UNRETWEET_URL(id), {
headers: authHeaders(credentials), headers: authHeaders(credentials),
method: 'POST' method: 'POST'
}) })
.then(response => {
if (response.ok) {
return response.json()
} else {
throw new Error('Error removing repeat')
}
})
.then((data) => parseStatus(data))
} }
const postStatus = ({credentials, status, spoilerText, visibility, sensitive, mediaIds, inReplyToStatusId, contentType, noAttachmentLinks}) => { const postStatus = ({credentials, status, spoilerText, visibility, sensitive, mediaIds = [], inReplyToStatusId, contentType}) => {
const idsText = mediaIds.join(',')
const form = new FormData() const form = new FormData()
form.append('status', status) form.append('status', status)
form.append('source', 'Pleroma FE') form.append('source', 'Pleroma FE')
if (noAttachmentLinks) form.append('no_attachment_links', noAttachmentLinks)
if (spoilerText) form.append('spoiler_text', spoilerText) if (spoilerText) form.append('spoiler_text', spoilerText)
if (visibility) form.append('visibility', visibility) if (visibility) form.append('visibility', visibility)
if (sensitive) form.append('sensitive', sensitive) if (sensitive) form.append('sensitive', sensitive)
if (contentType) form.append('content_type', contentType) if (contentType) form.append('content_type', contentType)
form.append('media_ids', idsText) mediaIds.forEach(val => {
form.append('media_ids[]', val)
})
if (inReplyToStatusId) { if (inReplyToStatusId) {
form.append('in_reply_to_status_id', inReplyToStatusId) form.append('in_reply_to_id', inReplyToStatusId)
} }
return fetch(STATUS_UPDATE_URL, { return fetch(MASTODON_POST_STATUS_URL, {
body: form, body: form,
method: 'POST', method: 'POST',
headers: authHeaders(credentials) headers: authHeaders(credentials)
@ -468,20 +521,20 @@ const postStatus = ({credentials, status, spoilerText, visibility, sensitive, me
} }
const deleteStatus = ({ id, credentials }) => { const deleteStatus = ({ id, credentials }) => {
return fetch(`${STATUS_DELETE_URL}/${id}.json`, { return fetch(MASTODON_DELETE_URL(id), {
headers: authHeaders(credentials), headers: authHeaders(credentials),
method: 'POST' method: 'DELETE'
}) })
} }
const uploadMedia = ({formData, credentials}) => { const uploadMedia = ({formData, credentials}) => {
return fetch(MEDIA_UPLOAD_URL, { return fetch(MASTODON_MEDIA_UPLOAD_URL, {
body: formData, body: formData,
method: 'POST', method: 'POST',
headers: authHeaders(credentials) headers: authHeaders(credentials)
}) })
.then((response) => response.text()) .then((data) => data.json())
.then((text) => (new DOMParser()).parseFromString(text, 'application/xml')) .then((data) => parseAttachment(data))
} }
const followImport = ({params, credentials}) => { const followImport = ({params, credentials}) => {
@ -522,30 +575,40 @@ const changePassword = ({credentials, password, newPassword, newPasswordConfirma
} }
const fetchMutes = ({credentials}) => { const fetchMutes = ({credentials}) => {
const url = '/api/qvitter/mutes.json' return promisedRequest(MASTODON_USER_MUTES_URL, { headers: authHeaders(credentials) })
.then((users) => users.map(parseUser))
return fetch(url, {
headers: authHeaders(credentials)
}).then((data) => data.json())
} }
const fetchBlocks = ({page, credentials}) => { const muteUser = ({id, credentials}) => {
return fetch(BLOCKS_URL, { return promisedRequest(MASTODON_MUTE_USER_URL(id), {
headers: authHeaders(credentials) headers: authHeaders(credentials),
}).then((data) => { method: 'POST'
if (data.ok) {
return data.json()
}
throw new Error('Error fetching blocks', data)
}) })
} }
const unmuteUser = ({id, credentials}) => {
return promisedRequest(MASTODON_UNMUTE_USER_URL(id), {
headers: authHeaders(credentials),
method: 'POST'
})
}
const fetchBlocks = ({credentials}) => {
return promisedRequest(MASTODON_USER_BLOCKS_URL, { headers: authHeaders(credentials) })
.then((users) => users.map(parseUser))
}
const fetchOAuthTokens = ({credentials}) => { const fetchOAuthTokens = ({credentials}) => {
const url = '/api/oauth_tokens.json' const url = '/api/oauth_tokens.json'
return fetch(url, { return fetch(url, {
headers: authHeaders(credentials) headers: authHeaders(credentials)
}).then((data) => data.json()) }).then((data) => {
if (data.ok) {
return data.json()
}
throw new Error('Error fetching auth tokens', data)
})
} }
const revokeOAuthToken = ({id, credentials}) => { const revokeOAuthToken = ({id, credentials}) => {
@ -588,6 +651,7 @@ const apiService = {
blockUser, blockUser,
unblockUser, unblockUser,
fetchUser, fetchUser,
fetchUserRelationship,
favorite, favorite,
unfavorite, unfavorite,
retweet, retweet,
@ -596,8 +660,9 @@ const apiService = {
deleteStatus, deleteStatus,
uploadMedia, uploadMedia,
fetchAllFollowing, fetchAllFollowing,
setUserMute,
fetchMutes, fetchMutes,
muteUser,
unmuteUser,
fetchBlocks, fetchBlocks,
fetchOAuthTokens, fetchOAuthTokens,
revokeOAuthToken, revokeOAuthToken,

View file

@ -10,16 +10,16 @@ const backendInteractorService = (credentials) => {
return apiService.fetchConversation({id, credentials}) return apiService.fetchConversation({id, credentials})
} }
const fetchFriends = ({id, page}) => { const fetchFriends = ({id, maxId, sinceId, limit}) => {
return apiService.fetchFriends({id, page, credentials}) return apiService.fetchFriends({id, maxId, sinceId, limit, credentials})
} }
const exportFriends = ({id}) => { const exportFriends = ({id}) => {
return apiService.exportFriends({id, credentials}) return apiService.exportFriends({id, credentials})
} }
const fetchFollowers = ({id, page}) => { const fetchFollowers = ({id, maxId, sinceId, limit}) => {
return apiService.fetchFollowers({id, page, credentials}) return apiService.fetchFollowers({id, maxId, sinceId, limit, credentials})
} }
const fetchAllFollowing = ({username}) => { const fetchAllFollowing = ({username}) => {
@ -30,6 +30,10 @@ const backendInteractorService = (credentials) => {
return apiService.fetchUser({id, credentials}) return apiService.fetchUser({id, credentials})
} }
const fetchUserRelationship = ({id}) => {
return apiService.fetchUserRelationship({id, credentials})
}
const followUser = (id) => { const followUser = (id) => {
return apiService.followUser({credentials, id}) return apiService.followUser({credentials, id})
} }
@ -58,12 +62,10 @@ const backendInteractorService = (credentials) => {
return timelineFetcherService.startFetching({timeline, store, credentials, userId, tag}) return timelineFetcherService.startFetching({timeline, store, credentials, userId, tag})
} }
const setUserMute = ({id, muted = true}) => {
return apiService.setUserMute({id, muted, credentials})
}
const fetchMutes = () => apiService.fetchMutes({credentials}) const fetchMutes = () => apiService.fetchMutes({credentials})
const fetchBlocks = (params) => apiService.fetchBlocks({credentials, ...params}) const muteUser = (id) => apiService.muteUser({credentials, id})
const unmuteUser = (id) => apiService.unmuteUser({credentials, id})
const fetchBlocks = () => apiService.fetchBlocks({credentials})
const fetchFollowRequests = () => apiService.fetchFollowRequests({credentials}) const fetchFollowRequests = () => apiService.fetchFollowRequests({credentials})
const fetchOAuthTokens = () => apiService.fetchOAuthTokens({credentials}) const fetchOAuthTokens = () => apiService.fetchOAuthTokens({credentials})
const revokeOAuthToken = (id) => apiService.revokeOAuthToken({id, credentials}) const revokeOAuthToken = (id) => apiService.revokeOAuthToken({id, credentials})
@ -92,11 +94,13 @@ const backendInteractorService = (credentials) => {
blockUser, blockUser,
unblockUser, unblockUser,
fetchUser, fetchUser,
fetchUserRelationship,
fetchAllFollowing, fetchAllFollowing,
verifyCredentials: apiService.verifyCredentials, verifyCredentials: apiService.verifyCredentials,
startFetching, startFetching,
setUserMute,
fetchMutes, fetchMutes,
muteUser,
unmuteUser,
fetchBlocks, fetchBlocks,
fetchOAuthTokens, fetchOAuthTokens,
revokeOAuthToken, revokeOAuthToken,

View file

@ -39,11 +39,11 @@ export const parseUser = (data) => {
return output return output
} }
output.name = null // missing // output.name = ??? missing
output.name_html = data.display_name output.name_html = addEmojis(data.display_name, data.emojis)
output.description = null // missing // output.description = ??? missing
output.description_html = data.note output.description_html = addEmojis(data.note, data.emojis)
// Utilize avatar_static for gif avatars? // Utilize avatar_static for gif avatars?
output.profile_image_url = data.avatar output.profile_image_url = data.avatar
@ -59,10 +59,14 @@ export const parseUser = (data) => {
output.statusnet_profile_url = data.url output.statusnet_profile_url = data.url
if (data.pleroma) { if (data.pleroma) {
const pleroma = data.pleroma const relationship = data.pleroma.relationship
output.follows_you = pleroma.follows_you
output.statusnet_blocking = pleroma.statusnet_blocking if (relationship) {
output.muted = pleroma.muted output.follows_you = relationship.followed_by
output.following = relationship.following
output.statusnet_blocking = relationship.blocking
output.muted = relationship.muting
}
} }
// Missing, trying to recover // Missing, trying to recover
@ -83,7 +87,7 @@ export const parseUser = (data) => {
output.friends_count = data.friends_count output.friends_count = data.friends_count
output.bot = null // missing // output.bot = ??? missing
output.statusnet_profile_url = data.statusnet_profile_url output.statusnet_profile_url = data.statusnet_profile_url
@ -124,17 +128,18 @@ export const parseUser = (data) => {
return output return output
} }
const parseAttachment = (data) => { export const parseAttachment = (data) => {
const output = {} const output = {}
const masto = !data.hasOwnProperty('oembed') const masto = !data.hasOwnProperty('oembed')
if (masto) { if (masto) {
// Not exactly same... // Not exactly same...
output.mimetype = data.type output.mimetype = data.pleroma ? data.pleroma.mime_type : data.type
output.meta = data.meta // not present in BE yet output.meta = data.meta // not present in BE yet
output.id = data.id
} else { } else {
output.mimetype = data.mimetype output.mimetype = data.mimetype
output.meta = null // missing // output.meta = ??? missing
} }
output.url = data.url output.url = data.url
@ -142,6 +147,14 @@ const parseAttachment = (data) => {
return output return output
} }
export const addEmojis = (string, emojis) => {
return emojis.reduce((acc, emoji) => {
return acc.replace(
new RegExp(`:${emoji.shortcode}:`, 'g'),
`<img src='${emoji.url}' alt='${emoji.shortcode}' class='emoji' />`
)
}, string)
}
export const parseStatus = (data) => { export const parseStatus = (data) => {
const output = {} const output = {}
@ -157,16 +170,17 @@ export const parseStatus = (data) => {
output.type = data.reblog ? 'retweet' : 'status' output.type = data.reblog ? 'retweet' : 'status'
output.nsfw = data.sensitive output.nsfw = data.sensitive
output.statusnet_html = data.content output.statusnet_html = addEmojis(data.content, data.emojis)
// Not exactly the same but works? // Not exactly the same but works?
output.text = data.content output.text = data.content
output.in_reply_to_status_id = data.in_reply_to_id output.in_reply_to_status_id = data.in_reply_to_id
output.in_reply_to_user_id = data.in_reply_to_account_id output.in_reply_to_user_id = data.in_reply_to_account_id
output.replies_count = data.replies_count
// Missing!! fix in UI? // Missing!! fix in UI?
output.in_reply_to_screen_name = null // output.in_reply_to_screen_name = ???
// Not exactly the same but works // Not exactly the same but works
output.statusnet_conversation_id = data.id output.statusnet_conversation_id = data.id
@ -176,11 +190,10 @@ export const parseStatus = (data) => {
} }
output.summary = data.spoiler_text output.summary = data.spoiler_text
output.summary_html = data.spoiler_text output.summary_html = addEmojis(data.spoiler_text, data.emojis)
output.external_url = data.url output.external_url = data.url
// FIXME missing!! // output.is_local = ??? missing
output.is_local = false
} else { } else {
output.favorited = data.favorited output.favorited = data.favorited
output.fave_num = data.fave_num output.fave_num = data.fave_num
@ -259,7 +272,7 @@ export const parseNotification = (data) => {
if (masto) { if (masto) {
output.type = mastoDict[data.type] || data.type output.type = mastoDict[data.type] || data.type
output.seen = null // missing // output.seen = ??? missing
output.status = parseStatus(data.status) output.status = parseStatus(data.status)
output.action = output.status // not sure output.action = output.status // not sure
output.from_profile = parseUser(data.account) output.from_profile = parseUser(data.account)
@ -282,5 +295,5 @@ export const parseNotification = (data) => {
const isNsfw = (status) => { const isNsfw = (status) => {
const nsfwRegex = /#nsfw/i const nsfwRegex = /#nsfw/i
return (status.tags || []).includes('nsfw') || !!status.text.match(nsfwRegex) return (status.tags || []).includes('nsfw') || !!(status.text || '').match(nsfwRegex)
} }

View file

@ -19,7 +19,7 @@ const fetchUser = (attempt, user, store) => new Promise((resolve, reject) => {
export const requestFollow = (user, store) => new Promise((resolve, reject) => { export const requestFollow = (user, store) => new Promise((resolve, reject) => {
store.state.api.backendInteractor.followUser(user.id) store.state.api.backendInteractor.followUser(user.id)
.then((updated) => { .then((updated) => {
store.commit('addNewUsers', [updated]) store.commit('updateUserRelationship', [updated])
// For locked users we just mark it that we sent the follow request // For locked users we just mark it that we sent the follow request
if (updated.locked) { if (updated.locked) {
@ -66,7 +66,7 @@ export const requestFollow = (user, store) => new Promise((resolve, reject) => {
export const requestUnfollow = (user, store) => new Promise((resolve, reject) => { export const requestUnfollow = (user, store) => new Promise((resolve, reject) => {
store.state.api.backendInteractor.unfollowUser(user.id) store.state.api.backendInteractor.unfollowUser(user.id)
.then((updated) => { .then((updated) => {
store.commit('addNewUsers', [updated]) store.commit('updateUserRelationship', [updated])
resolve({ resolve({
updated updated
}) })

View file

@ -0,0 +1,74 @@
const DIRECTION_LEFT = [-1, 0]
const DIRECTION_RIGHT = [1, 0]
const DIRECTION_UP = [0, -1]
const DIRECTION_DOWN = [0, 1]
const deltaCoord = (oldCoord, newCoord) => [newCoord[0] - oldCoord[0], newCoord[1] - oldCoord[1]]
const touchEventCoord = e => ([e.touches[0].screenX, e.touches[0].screenY])
const vectorLength = v => Math.sqrt(v[0] * v[0] + v[1] * v[1])
const perpendicular = v => [v[1], -v[0]]
const dotProduct = (v1, v2) => v1[0] * v2[0] + v1[1] * v2[1]
const project = (v1, v2) => {
const scalar = (dotProduct(v1, v2) / dotProduct(v2, v2))
return [scalar * v2[0], scalar * v2[1]]
}
// direction: either use the constants above or an arbitrary 2d vector.
// threshold: how many Px to move from touch origin before checking if the
// callback should be called.
// divergentTolerance: a scalar for much of divergent direction we tolerate when
// above threshold. for example, with 1.0 we only call the callback if
// divergent component of delta is < 1.0 * direction component of delta.
const swipeGesture = (direction, onSwipe, threshold = 30, perpendicularTolerance = 1.0) => {
return {
direction,
onSwipe,
threshold,
perpendicularTolerance,
_startPos: [0, 0],
_swiping: false
}
}
const beginSwipe = (event, gesture) => {
gesture._startPos = touchEventCoord(event)
gesture._swiping = true
}
const updateSwipe = (event, gesture) => {
if (!gesture._swiping) return
// movement too small
const delta = deltaCoord(gesture._startPos, touchEventCoord(event))
if (vectorLength(delta) < gesture.threshold) return
// movement is opposite from direction
if (dotProduct(delta, gesture.direction) < 0) return
// movement perpendicular to direction is too much
const towardsDir = project(delta, gesture.direction)
const perpendicularDir = perpendicular(gesture.direction)
const towardsPerpendicular = project(delta, perpendicularDir)
if (
vectorLength(towardsDir) * gesture.perpendicularTolerance <
vectorLength(towardsPerpendicular)
) return
gesture.onSwipe()
gesture._swiping = false
}
const GestureService = {
DIRECTION_LEFT,
DIRECTION_RIGHT,
DIRECTION_UP,
DIRECTION_DOWN,
swipeGesture,
beginSwipe,
updateSwipe
}
export default GestureService

View file

@ -1,13 +1,16 @@
import utils from './utils.js' import utils from './utils.js'
import { parseUser } from '../entity_normalizer/entity_normalizer.service.js'
const search = ({query, store}) => { const search = ({query, store}) => {
return utils.request({ return utils.request({
store, store,
url: '/api/pleroma/search_user', url: '/api/v1/accounts/search',
params: { params: {
query q: query
} }
}).then((data) => data.json()) })
.then((data) => data.json())
.then((data) => data.map(parseUser))
} }
const UserSearch = { const UserSearch = {
search search

View file

@ -4,7 +4,7 @@ import apiService from '../api/api.service.js'
const postStatus = ({ store, status, spoilerText, visibility, sensitive, media = [], inReplyToStatusId = undefined, contentType = 'text/plain' }) => { const postStatus = ({ store, status, spoilerText, visibility, sensitive, media = [], inReplyToStatusId = undefined, contentType = 'text/plain' }) => {
const mediaIds = map(media, 'id') const mediaIds = map(media, 'id')
return apiService.postStatus({credentials: store.state.users.currentUser.credentials, status, spoilerText, visibility, sensitive, mediaIds, inReplyToStatusId, contentType, noAttachmentLinks: store.state.instance.noAttachmentLinks}) return apiService.postStatus({credentials: store.state.users.currentUser.credentials, status, spoilerText, visibility, sensitive, mediaIds, inReplyToStatusId, contentType})
.then((data) => { .then((data) => {
if (!data.error) { if (!data.error) {
store.dispatch('addNewStatuses', { store.dispatch('addNewStatuses', {
@ -26,25 +26,7 @@ const postStatus = ({ store, status, spoilerText, visibility, sensitive, media =
const uploadMedia = ({ store, formData }) => { const uploadMedia = ({ store, formData }) => {
const credentials = store.state.users.currentUser.credentials const credentials = store.state.users.currentUser.credentials
return apiService.uploadMedia({ credentials, formData }).then((xml) => { return apiService.uploadMedia({ credentials, formData })
// Firefox and Chrome treat method differently...
let link = xml.getElementsByTagName('link')
if (link.length === 0) {
link = xml.getElementsByTagName('atom:link')
}
link = link[0]
const mediaData = {
id: xml.getElementsByTagName('media_id')[0].textContent,
url: xml.getElementsByTagName('media_url')[0].textContent,
image: link.getAttribute('href'),
mimetype: link.getAttribute('type')
}
return mediaData
})
} }
const statusPosterService = { const statusPosterService = {

View file

@ -19,15 +19,19 @@ const fetchAndUpdate = ({store, credentials, timeline = 'friends', older = false
const args = { timeline, credentials } const args = { timeline, credentials }
const rootState = store.rootState || store.state const rootState = store.rootState || store.state
const timelineData = rootState.statuses.timelines[camelCase(timeline)] const timelineData = rootState.statuses.timelines[camelCase(timeline)]
const hideMutedPosts = typeof rootState.config.hideMutedPosts === 'undefined'
? rootState.instance.hideMutedPosts
: rootState.config.hideMutedPosts
if (older) { if (older) {
args['until'] = until || timelineData.minVisibleId args['until'] = until || timelineData.minId
} else { } else {
args['since'] = timelineData.maxId args['since'] = timelineData.maxId
} }
args['userId'] = userId args['userId'] = userId
args['tag'] = tag args['tag'] = tag
args['withMuted'] = !hideMutedPosts
const numStatusesBeforeFetch = timelineData.statuses.length const numStatusesBeforeFetch = timelineData.statuses.length

View file

@ -1,7 +1,7 @@
import { includes } from 'lodash' import { includes } from 'lodash'
const generateProfileLink = (id, screenName, restrictedNicknames) => { const generateProfileLink = (id, screenName, restrictedNicknames) => {
const complicated = (isExternal(screenName) || includes(restrictedNicknames, screenName)) const complicated = !screenName || (isExternal(screenName) || includes(restrictedNicknames, screenName))
return { return {
name: (complicated ? 'external-user-profile' : 'user-profile'), name: (complicated ? 'external-user-profile' : 'user-profile'),
params: (complicated ? { id } : { name: screenName }) params: (complicated ? { id } : { name: screenName })

View file

@ -0,0 +1,6 @@
export const extractCommit = versionString => {
const regex = /-g(\w+)$/i
const matches = versionString.match(regex)
return matches ? matches[1] : ''
}

0
static/font/LICENSE.txt Normal file → Executable file
View file

0
static/font/README.txt Normal file → Executable file
View file

6
static/font/config.json Normal file → Executable file
View file

@ -233,6 +233,12 @@
"css": "play-circled", "css": "play-circled",
"code": 61764, "code": 61764,
"src": "fontawesome" "src": "fontawesome"
},
{
"uid": "d35a1d35efeb784d1dc9ac18b9b6c2b6",
"css": "pencil",
"code": 59416,
"src": "fontawesome"
} }
] ]
} }

0
static/font/css/animation.css Normal file → Executable file
View file

1
static/font/css/fontello-codes.css vendored Normal file → Executable file
View file

@ -23,6 +23,7 @@
.icon-plus:before { content: '\e815'; } /* '' */ .icon-plus:before { content: '\e815'; } /* '' */
.icon-adjust:before { content: '\e816'; } /* '' */ .icon-adjust:before { content: '\e816'; } /* '' */
.icon-edit:before { content: '\e817'; } /* '' */ .icon-edit:before { content: '\e817'; } /* '' */
.icon-pencil:before { content: '\e818'; } /* '' */
.icon-spin3:before { content: '\e832'; } /* '' */ .icon-spin3:before { content: '\e832'; } /* '' */
.icon-spin4:before { content: '\e834'; } /* '' */ .icon-spin4:before { content: '\e834'; } /* '' */
.icon-link-ext:before { content: '\f08e'; } /* '' */ .icon-link-ext:before { content: '\f08e'; } /* '' */

13
static/font/css/fontello-embedded.css vendored Normal file → Executable file

File diff suppressed because one or more lines are too long

1
static/font/css/fontello-ie7-codes.css vendored Normal file → Executable file
View file

@ -23,6 +23,7 @@
.icon-plus { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe815;&nbsp;'); } .icon-plus { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe815;&nbsp;'); }
.icon-adjust { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe816;&nbsp;'); } .icon-adjust { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe816;&nbsp;'); }
.icon-edit { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe817;&nbsp;'); } .icon-edit { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe817;&nbsp;'); }
.icon-pencil { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe818;&nbsp;'); }
.icon-spin3 { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe832;&nbsp;'); } .icon-spin3 { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe832;&nbsp;'); }
.icon-spin4 { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe834;&nbsp;'); } .icon-spin4 { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe834;&nbsp;'); }
.icon-link-ext { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xf08e;&nbsp;'); } .icon-link-ext { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xf08e;&nbsp;'); }

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