Merge remote-tracking branch 'upstream/develop' into mastoapi/followers

* upstream/develop: (87 commits)
  review
  Update attachment normalizer
  Add fallback for attachments uploaded via the other platforms
  Get correct mimetype through entity_normalizer
  Set default parameter
  Switch to mastoapi for posting status and uploading media
  Revert changes
  prevent text pasting if image is pasted
  remove border radius of suggested emojis
  #450 - dispatch login after saved state is loaded
  #448 - fix timeline fetch error when status text is null
  #451 - add class to username span
  No need to fetch mutes on load anymore 🙌
  switch to mastoapi
  switch to mastoapi
  masto api sends muted property now
  No need to fetch user data using old api anymore 🎉
  Switch to mastoapi
  reactivity fixes
  less hackery, more direct usage of mastoapi
  ...
This commit is contained in:
Henry Jameson 2019-03-25 20:32:12 +02:00
commit 50960c7cfc
86 changed files with 1499 additions and 751 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;
} }

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('scopeOptionsEnabled') ? 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('scopeOptionsEnabled')
} 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
@ -169,14 +198,71 @@ const afterStoreSetup = ({ store, i18n }) => {
store.dispatch('setInstanceOption', { name: 'chatAvailable', value: features.includes('chat') }) store.dispatch('setInstanceOption', { name: 'chatAvailable', value: features.includes('chat') })
store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') }) store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') })
store.dispatch('setInstanceOption', { name: 'postFormats', value: metadata.postFormats })
store.dispatch('setInstanceOption', { name: 'restrictedNicknames', value: metadata.restrictedNicknames }) 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) {
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

@ -8,8 +8,8 @@
</div> </div>
<div class="basic-user-card-collapsed-content" v-else> <div class="basic-user-card-collapsed-content" v-else>
<div :title="user.name" class="basic-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="basic-user-card-screen-name" :to="userProfileLink(user)"> <router-link class="basic-user-card-screen-name" :to="userProfileLink(user)">
@ -52,6 +52,14 @@
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 {

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,4 +1,5 @@
import { reduce, filter } from 'lodash' import { reduce, filter } 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) => {
@ -25,7 +26,8 @@ const sortAndFilterConversation = (conversation) => {
const conversation = { const conversation = {
data () { data () {
return { return {
highlight: null highlight: null,
converationStatusIds: []
} }
}, },
props: [ props: [
@ -36,6 +38,15 @@ const conversation = {
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,9 +59,11 @@ const conversation = {
return [] return []
} }
const conversationId = this.status.statusnet_conversation_id const statusesObject = this.$store.state.statuses.allStatusesObject
const statuses = this.$store.state.statuses.allStatuses const conversation = this.idsToShow.reduce((acc, id) => {
const conversation = filter(statuses, { statusnet_conversation_id: conversationId }) acc.push(statusesObject[id])
return acc
}, [])
return sortAndFilterConversation(conversation) return sortAndFilterConversation(conversation)
}, },
replies () { replies () {
@ -83,9 +96,15 @@ const conversation = {
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),
this.statusId,
descendants.map(_ => _.id)))
})
.then(() => this.setHighlight(this.statusId)) .then(() => this.setHighlight(this.statusId))
} else { } else {
const id = this.$route.params.id const id = this.$route.params.id

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

@ -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')
}, },
@ -76,6 +82,18 @@ const ImageCropper = {
this.submitting = false this.submitting = false
}) })
}, },
submitWithoutCropping () {
this.submitting = true
this.avatarUploadError = null
this.submitHandler(false, this.dataUrl)
.then(() => this.destroy())
.catch((err) => {
this.submitError = err
})
.finally(() => {
this.submitting = false
})
},
pickImage () { pickImage () {
this.$refs.input.click() this.$refs.input.click()
}, },

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

@ -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

@ -222,6 +222,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 +296,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

@ -20,6 +20,7 @@
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"
@ -83,10 +84,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>
@ -286,8 +287,6 @@
img { img {
width: 24px; width: 24px;
height: 24px; height: 24px;
border-radius: $fallback--avatarRadius;
border-radius: var(--avatarRadius, $fallback--avatarRadius);
object-fit: contain; object-fit: contain;
} }

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,
@ -78,7 +88,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: {
@ -96,7 +109,13 @@ const settings = {
postFormats () { postFormats () {
return this.$store.state.instance.postFormats || [] return this.$store.state.instance.postFormats || []
}, },
instanceSpecificPanelPresent () { return this.$store.state.instance.showInstanceSpecificPanel } instanceSpecificPanelPresent () { return this.$store.state.instance.showInstanceSpecificPanel },
frontendVersionLink () {
return pleromaFeCommitUrl + this.frontendVersion
},
backendVersionLink () {
return pleromaBeCommitUrl + extractCommit(this.backendVersion)
}
}, },
watch: { watch: {
hideAttachmentsLocal (value) { hideAttachmentsLocal (value) {
@ -163,6 +182,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">
@ -261,6 +265,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

@ -15,12 +15,7 @@
</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>
@ -119,14 +114,14 @@
} }
.side-drawer-container-open { .side-drawer-container-open {
transition-delay: 0.0s; transition: 0.35s;
transition-property: left; transition-property: background-color;
background-color: rgba(0, 0, 0, 0.5);
} }
.side-drawer-container-closed { .side-drawer-container-closed {
left: -100%; left: -100%;
transition-delay: 0.5s; background-color: rgba(0, 0, 0, 0);
transition-property: left;
} }
.side-drawer-click-outside { .side-drawer-click-outside {

View File

@ -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 () {

View File

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

View File

@ -1,4 +1,5 @@
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'
@ -15,6 +16,9 @@ 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: {
classes () { classes () {
return [{ return [{
@ -96,7 +100,8 @@ export default {
} }
}, },
components: { components: {
UserAvatar UserAvatar,
RemoteFollow
}, },
methods: { methods: {
followUser () { followUser () {
@ -116,24 +121,16 @@ export default {
}) })
}, },
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])
store.commit('removeStatus', { timeline: 'friends', userId: this.user.id })
store.commit('removeStatus', { timeline: 'public', userId: this.user.id })
store.commit('removeStatus', { timeline: 'publicAndExternal', userId: this.user.id })
})
}, },
unblockUser () { 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

@ -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'>
@ -159,6 +153,18 @@
&-bio { &-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 // Modifiers
@ -363,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;

View File

@ -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,19 +136,19 @@ 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 () { $route () {
this.$refs.tabSwitcher.activateTab(0)() this.$refs.tabSwitcher.activateTab(0)()

View File

@ -11,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" />
@ -25,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"

View File

@ -158,7 +158,13 @@ const UserSettings = {
reader.readAsDataURL(file) reader.readAsDataURL(file)
}, },
submitAvatar (cropper, file) { submitAvatar (cropper, file) {
const img = cropper.getCroppedCanvas().toDataURL(file.type) 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

@ -192,6 +192,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…",

View File

@ -71,7 +71,9 @@
"account_not_locked_warning_link": "uzamčen", "account_not_locked_warning_link": "uzamčen",
"attachments_sensitive": "Označovat přílohy jako citlivé", "attachments_sensitive": "Označovat přílohy jako citlivé",
"content_type": { "content_type": {
"plain_text": "Prostý text" "text/plain": "Prostý text",
"text/html": "HTML",
"text/markdown": "Markdown"
}, },
"content_warning": "Předmět (volitelný)", "content_warning": "Předmět (volitelný)",
"default": "Právě jsem přistál v L.A.", "default": "Právě jsem přistál v L.A.",
@ -95,7 +97,7 @@
"new_captcha": "Kliknutím na obrázek získáte novou CAPTCHA", "new_captcha": "Kliknutím na obrázek získáte novou CAPTCHA",
"username_placeholder": "např. lain", "username_placeholder": "např. lain",
"fullname_placeholder": "např. Lain Iwakura", "fullname_placeholder": "např. Lain Iwakura",
"bio_placeholder": "např.\nNazdar, jsem Lain\nJsem anime dívka a žiji v příměstském Japonsku. Možná mě znáte z Wired.", "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": { "validations": {
"username_required": "nemůže být prázdné", "username_required": "nemůže být prázdné",
"fullname_required": "nemůže být prázdné", "fullname_required": "nemůže být prázdné",
@ -204,7 +206,7 @@
"radii_help": "Nastavit zakulacení rohů rozhraní (v pixelech)", "radii_help": "Nastavit zakulacení rohů rozhraní (v pixelech)",
"replies_in_timeline": "Odpovědi v časové ose", "replies_in_timeline": "Odpovědi v časové ose",
"reply_link_preview": "Povolit náhledy odkazu pro odpověď při přejetí myši", "reply_link_preview": "Povolit náhledy odkazu pro odpověď při přejetí myši",
"reply_visibility_all": "Zobrazit všechny odpovědiShow all replies", "reply_visibility_all": "Zobrazit všechny odpovědi",
"reply_visibility_following": "Zobrazit pouze odpovědi směřované na mě nebo uživatele, které sleduji", "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ě", "reply_visibility_self": "Zobrazit pouze odpovědi směřované na mě",
"saving_err": "Chyba při ukládání nastavení", "saving_err": "Chyba při ukládání nastavení",
@ -221,7 +223,6 @@
"subject_line_mastodon": "Jako u Mastodonu: zkopírovat tak, jak je", "subject_line_mastodon": "Jako u Mastodonu: zkopírovat tak, jak je",
"subject_line_noop": "Nekopírovat", "subject_line_noop": "Nekopírovat",
"post_status_content_type": "Publikovat typ obsahu příspěvku", "post_status_content_type": "Publikovat typ obsahu příspěvku",
"status_content_type_plain": "Prostý text",
"stop_gifs": "Přehrávat GIFy při přejetí myši", "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", "streaming": "Povolit automatické streamování nových příspěvků při rolování nahoru",
"text": "Text", "text": "Text",
@ -339,7 +340,7 @@
"button": "Tlačítko", "button": "Tlačítko",
"text": "Spousta dalšího {0} a {1}", "text": "Spousta dalšího {0} a {1}",
"mono": "obsahu", "mono": "obsahu",
"input": "Just landed in L.A.", "input": "Právě jsem přistál v L.A.",
"faint_link": "pomocný manuál", "faint_link": "pomocný manuál",
"fine_print": "Přečtěte si náš {0} a nenaučte se nic užitečného!", "fine_print": "Přečtěte si náš {0} a nenaučte se nic užitečného!",
"header_faint": "Tohle je v pohodě", "header_faint": "Tohle je v pohodě",
@ -361,7 +362,7 @@
"no_statuses": "Žádné příspěvky" "no_statuses": "Žádné příspěvky"
}, },
"status": { "status": {
"reply_to": "Odpovědět uživateli", "reply_to": "Odpověď uživateli",
"replies_list": "Odpovědi:" "replies_list": "Odpovědi:"
}, },
@ -413,7 +414,7 @@
"upload":{ "upload":{
"error": { "error": {
"base": "Nahrávání selhalo.", "base": "Nahrávání selhalo.",
"file_too_big": "Soubor je úříliš velký [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]", "file_too_big": "Soubor je příliš velký [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]",
"default": "Zkuste to znovu později" "default": "Zkuste to znovu později"
}, },
"file_size_units": { "file_size_units": {

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": {
@ -152,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",
@ -347,6 +349,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

@ -71,7 +71,7 @@
"account_not_locked_warning_link": "ŝlosita", "account_not_locked_warning_link": "ŝlosita",
"attachments_sensitive": "Marki kunsendaĵojn kiel konsternajn", "attachments_sensitive": "Marki kunsendaĵojn kiel konsternajn",
"content_type": { "content_type": {
"plain_text": "Plata teksto" "text/plain": "Plata teksto"
}, },
"content_warning": "Temo (malnepra)", "content_warning": "Temo (malnepra)",
"default": "Ĵus alvenis al la Universala Kongreso!", "default": "Ĵus alvenis al la Universala Kongreso!",

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.",

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": "はねだくうこうに、つきました。",

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

@ -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

@ -59,19 +59,21 @@
"broken_favorite": "Estatut desconegut, sèm a lo cercar...", "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 notificaciones mai ancianas", "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",
"no_more_notifications": "Pas mai de notificacions" "no_more_notifications": "Pas mai de notificacions"
}, },
"post_status": { "post_status": {
"new_status": "Publicar destatuts novèls", "new_status": "Publicar destatuts novèls",
"account_not_locked_warning": "Vòstre compte es pas {0}. Qual que siá pòt vos seguir per veire vòstras publicacions destinadas pas qu'a vòstres seguidors.", "account_not_locked_warning": "Vòstre compte es pas {0}. Qual que siá pòt vos seguir per veire vòstras publicacions destinadas pas qua 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)", "content_warning": "Avís de contengut (opcional)",
"default": "Escrivètz aquí vòstre estatut.", "default": "Escrivètz aquí vòstre estatut.",
@ -118,12 +120,12 @@
"blocks_tab": "Blocatges", "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", "collapse_subject": "Replegar las publicacions amb de subjèctes",
"composing": "Escritura", "composing": "Escritura",
"confirm_new_password": "Confirmatz lo nòu senhal", "confirm_new_password": "Confirmatz lo nòu senhal",
@ -134,7 +136,7 @@
"default_vis": "Nivèl de visibilitat per defaut", "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.",
"avatar_size_instruction": "La talha minimum recomandada pels imatges davatar es 150x150 pixèls.", "avatar_size_instruction": "La talha minimum recomandada pels imatges davatar es 150x150 pixèls.",
"export_theme": "Enregistrar la preconfiguracion", "export_theme": "Enregistrar la preconfiguracion",
@ -154,14 +156,14 @@
"hide_isp": "Amagar lo panèl especial instància", "hide_isp": "Amagar lo panèl especial instància",
"preload_images": "Precargar los imatges", "preload_images": "Precargar los imatges",
"use_one_click_nsfw": "Dobrir las pèças juntas NSFW amb un clic", "use_one_click_nsfw": "Dobrir las pèças juntas NSFW amb un clic",
"hide_post_stats": "Amagar los estatistics de publicacion (ex. lo ombre de favorits)", "hide_post_stats": "Amagar las estatisticas de publicacion (ex. lo nombre 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", "hide_filtered_statuses": "Amagar los estatuts filtrats",
"import_followers_from_a_csv_file": "Importar los seguidors dun fichièr csv", "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",
"inputRadius": "Camps tèxte", "inputRadius": "Camps tèxte",
"checkboxRadius": "Casas de marcar", "checkboxRadius": "Casas de marcar",
"instance_default": "(defaut : {value})", "instance_default": "(defaut: {value})",
"instance_default_simple": "(defaut)", "instance_default_simple": "(defaut)",
"interface": "Interfàcia", "interface": "Interfàcia",
"interfaceLanguage": "Lenga de linterfàcia", "interfaceLanguage": "Lenga de linterfàcia",
@ -172,7 +174,7 @@
"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)",
"mutes_tab": "Agamats", "mutes_tab": "Agamats",
"play_videos_in_modal": "Legir las vidèoas dirèctament dins la visualizaira mèdia", "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", "use_contain_fit": "Talhar pas las pèças juntas per las vinhetas",
"name": "Nom", "name": "Nom",
"name_bio": "Nom & Bio", "name_bio": "Nom & Bio",
@ -223,7 +225,7 @@
"post_status_content_type": "Publicar lo tipe de contengut dels estatuts", "post_status_content_type": "Publicar lo tipe de contengut dels estatuts",
"stop_gifs": "Lançar los GIFs al subrevòl", "stop_gifs": "Lançar los GIFs al subrevòl",
"streaming": "Activar lo cargament automatic dels novèls estatus en anar amont", "streaming": "Activar lo cargament automatic dels novèls estatus en anar amont",
"text": "Tèxt", "text": "Tèxte",
"theme": "Tèma", "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_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_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.",
@ -234,6 +236,117 @@
"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": {
@ -241,19 +354,21 @@
"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": { "status": {
"reply_to": "Respondre à", "reply_to": "Respond a",
"replies_list": "Responsas:" "replies_list": "Responsas:"
}, },
"user_card": { "user_card": {
"approve": "Validar", "approve": "Validar",
"block": "Blocar", "block": "Blocar",
"blocked": "Blocat !", "blocked": "Blocat!",
"deny": "Refusar", "deny": "Refusar",
"favorites": "Favorits", "favorites": "Favorits",
"follow": "Seguir", "follow": "Seguir",
@ -263,8 +378,8 @@
"follow_unfollow": "Quitar de seguir", "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!", "its_you": "Sètz vos!",
"media": "Mèdia", "media": "Mèdia",
"mute": "Amagar", "mute": "Amagar",

View File

@ -51,7 +51,7 @@
"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": "Busca de usuário", "user_search": "Buscar usuários",
"who_to_follow": "Quem seguir", "who_to_follow": "Quem seguir",
"preferences": "Preferências" "preferences": "Preferências"
}, },
@ -67,11 +67,11 @@
}, },
"post_status": { "post_status": {
"new_status": "Postar novo status", "new_status": "Postar novo status",
"account_not_locked_warning": "Sua conta não está {0}. Qualquer pessoa pode te seguir para ver seus posts restritos.", "account_not_locked_warning": "Sua conta não é {0}. Qualquer pessoa pode te seguir e ver seus posts privados (só para seguidores).",
"account_not_locked_warning_link": "fechada", "account_not_locked_warning_link": "restrita",
"attachments_sensitive": "Marcar anexos como sensíveis", "attachments_sensitive": "Marcar anexos como sensíveis",
"content_type": { "content_type": {
"plain_text": "Texto puro" "text/plain": "Texto puro"
}, },
"content_warning": "Assunto (opcional)", "content_warning": "Assunto (opcional)",
"default": "Acabei de chegar no Rio!", "default": "Acabei de chegar no Rio!",
@ -115,7 +115,7 @@
"avatarRadius": "Avatares", "avatarRadius": "Avatares",
"background": "Pano de Fundo", "background": "Pano de Fundo",
"bio": "Biografia", "bio": "Biografia",
"blocks_tab": "Blocos", "blocks_tab": "Bloqueios",
"btnRadius": "Botões", "btnRadius": "Botões",
"cBlue": "Azul (Responder, seguir)", "cBlue": "Azul (Responder, seguir)",
"cGreen": "Verde (Repetir)", "cGreen": "Verde (Repetir)",
@ -125,7 +125,7 @@
"change_password_error": "Houve um erro ao modificar sua senha.", "change_password_error": "Houve um erro ao modificar sua senha.",
"changed_password": "Senha modificada com sucesso!", "changed_password": "Senha modificada com sucesso!",
"collapse_subject": "Esconder posts com assunto", "collapse_subject": "Esconder posts com assunto",
"composing": "Escrevendo", "composing": "Escrita",
"confirm_new_password": "Confirmar nova senha", "confirm_new_password": "Confirmar nova senha",
"current_avatar": "Seu avatar atual", "current_avatar": "Seu avatar atual",
"current_password": "Sua senha atual", "current_password": "Sua senha atual",
@ -139,7 +139,7 @@
"avatar_size_instruction": "O tamanho mínimo recomendado para imagens de avatar é 150x150 pixels.", "avatar_size_instruction": "O tamanho mínimo recomendado para imagens de avatar é 150x150 pixels.",
"export_theme": "Salvar predefinições", "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_export": "Exportar quem você segue", "follow_export": "Exportar quem você segue",
"follow_export_button": "Exportar quem você segue para um arquivo CSV", "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_export_processing": "Processando. Em breve você receberá a solicitação de download do arquivo",
@ -178,7 +178,7 @@
"name_bio": "Nome & Biografia", "name_bio": "Nome & Biografia",
"new_password": "Nova senha", "new_password": "Nova senha",
"notification_visibility": "Tipos de notificação para mostrar", "notification_visibility": "Tipos de notificação para mostrar",
"notification_visibility_follows": "Seguidos", "notification_visibility_follows": "Seguidas",
"notification_visibility_likes": "Favoritos", "notification_visibility_likes": "Favoritos",
"notification_visibility_mentions": "Menções", "notification_visibility_mentions": "Menções",
"notification_visibility_repeats": "Repetições", "notification_visibility_repeats": "Repetições",
@ -187,7 +187,7 @@
"no_mutes": "Sem silenciados", "no_mutes": "Sem silenciados",
"hide_follows_description": "Não mostrar quem estou seguindo", "hide_follows_description": "Não mostrar quem estou seguindo",
"hide_followers_description": "Não mostrar quem me segue", "hide_followers_description": "Não mostrar quem me segue",
"show_admin_badge": "Mostrar distintivo de Administrador em meu perfil", "show_admin_badge": "Mostrar título de Administrador em meu perfil",
"show_moderator_badge": "Mostrar título de Moderador em meu perfil", "show_moderator_badge": "Mostrar título de Moderador em meu perfil",
"nsfw_clickthrough": "Habilitar clique para ocultar anexos sensíveis", "nsfw_clickthrough": "Habilitar clique para ocultar anexos sensíveis",
"oauth_tokens": "Token OAuth", "oauth_tokens": "Token OAuth",
@ -201,9 +201,9 @@
"profile_background": "Pano de fundo de perfil", "profile_background": "Pano de fundo de perfil",
"profile_banner": "Capa de perfil", "profile_banner": "Capa de perfil",
"profile_tab": "Perfil", "profile_tab": "Perfil",
"radii_help": "Arredondar arestas da interface (em píxeis)", "radii_help": "Arredondar arestas da interface (em pixel)",
"replies_in_timeline": "Respostas na linha do tempo", "replies_in_timeline": "Respostas na linha do tempo",
"reply_link_preview": "Habilitar a pré-visualização de link de respostas ao passar o mouse.", "reply_link_preview": "Habilitar a pré-visualização de de respostas ao passar o mouse.",
"reply_visibility_all": "Mostrar todas as respostas", "reply_visibility_all": "Mostrar todas as respostas",
"reply_visibility_following": "Só mostrar respostas direcionadas a mim ou a usuários que sigo", "reply_visibility_following": "Só mostrar respostas direcionadas a mim ou a usuários que sigo",
"reply_visibility_self": "Só mostrar respostas direcionadas a mim", "reply_visibility_self": "Só mostrar respostas direcionadas a mim",
@ -212,7 +212,7 @@
"security_tab": "Segurança", "security_tab": "Segurança",
"scope_copy": "Copiar opções de privacidade ao responder (Mensagens diretas sempre copiam)", "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",
"subject_input_always_show": "Sempre mostrar campo de assunto", "subject_input_always_show": "Sempre mostrar campo de assunto",
@ -220,9 +220,9 @@
"subject_line_email": "Como em email: \"re: assunto\"", "subject_line_email": "Como em email: \"re: assunto\"",
"subject_line_mastodon": "Como o Mastodon: copiar como está", "subject_line_mastodon": "Como o Mastodon: copiar como está",
"subject_line_noop": "Não copiar", "subject_line_noop": "Não copiar",
"post_status_content_type": "Postar tipo de conteúdo do status", "post_status_content_type": "Tipo de conteúdo do status",
"stop_gifs": "Reproduzir GIFs ao passar o cursor em cima", "stop_gifs": "Reproduzir GIFs ao passar o cursor",
"streaming": "Habilitar o fluxo automático de postagens quando ao topo da página", "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.",
@ -235,7 +235,7 @@
"false": "não", "false": "não",
"true": "sim" "true": "sim"
}, },
"notifications": "Notifications", "notifications": "Notificações",
"enable_web_push_notifications": "Habilitar notificações web push", "enable_web_push_notifications": "Habilitar notificações web push",
"style": { "style": {
"switcher": { "switcher": {
@ -245,7 +245,7 @@
"keep_roundness": "Manter arredondado", "keep_roundness": "Manter arredondado",
"keep_fonts": "Manter fontes", "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.", "save_load_hint": "Manter as opções preserva as opções atuais ao selecionar ou carregar temas; também salva as opções ao exportar um tempo. Quanto todos os campos estiverem desmarcados, tudo será salvo ao exportar o tema.",
"reset": "Voltar ao padrão", "reset": "Restaurar o padrão",
"clear_all": "Limpar tudo", "clear_all": "Limpar tudo",
"clear_opacity": "Limpar opacidade" "clear_opacity": "Limpar opacidade"
}, },
@ -319,7 +319,7 @@
}, },
"fonts": { "fonts": {
"_tab_label": "Fontes", "_tab_label": "Fontes",
"help": "Selecionar fonte dos elementos da interface. Para fonte \"personalizada\" você deve entrar exatamente o nome da fonte no sistema.", "help": "Selecione as fontes dos elementos da interface. Para fonte \"personalizada\" você deve inserir o mesmo nome da fonte no sistema.",
"components": { "components": {
"interface": "Interface", "interface": "Interface",
"input": "Campo de entrada", "input": "Campo de entrada",
@ -383,7 +383,7 @@
"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": "Desbloquear",
"unblock_progress": "Desbloqueando...", "unblock_progress": "Desbloqueando...",

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,15 +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) { if (store.state.oauth.token) {
store.dispatch('loginUser', store.state.oauth.token) store.dispatch('loginUser', store.state.oauth.token)
} }

View File

@ -53,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,
@ -75,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

@ -18,6 +18,7 @@ const defaultState = {
scopeOptionsEnabled: true, scopeOptionsEnabled: true,
formattingOptionsEnabled: false, formattingOptionsEnabled: false,
alwaysShowSubjectInput: true, alwaysShowSubjectInput: true,
hideMutedPosts: false,
collapseMessageWithSubject: false, collapseMessageWithSubject: false,
hidePostStats: false, hidePostStats: false,
hideUserStats: false, hideUserStats: false,
@ -48,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, first, 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'
@ -72,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}
@ -80,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}
} }
} }
@ -333,6 +336,7 @@ export const mutations = {
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 })
}, },

View File

@ -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 }
} }
@ -91,10 +91,31 @@ export const mutations = {
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,39 +170,51 @@ 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) => {
@ -292,6 +327,7 @@ 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())
@ -321,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

@ -9,29 +9,19 @@ const FAVORITE_URL = '/api/favorites/create'
const UNFAVORITE_URL = '/api/favorites/destroy' const UNFAVORITE_URL = '/api/favorites/destroy'
const RETWEET_URL = '/api/statuses/retweet' const RETWEET_URL = '/api/statuses/retweet'
const UNRETWEET_URL = '/api/statuses/unretweet' const UNRETWEET_URL = '/api/statuses/unretweet'
const STATUS_UPDATE_URL = '/api/statuses/update.json'
const STATUS_DELETE_URL = '/api/statuses/destroy' 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 DM_TIMELINE_URL = '/api/statuses/dm_timeline.json'
const BLOCKS_URL = '/api/statuses/blocks.json'
const FOLLOWING_URL = '/api/friendships/create.json' const FOLLOWING_URL = '/api/friendships/create.json'
const UNFOLLOWING_URL = '/api/friendships/destroy.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 +33,22 @@ 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_FOLLOWING_URL = id => `/api/v1/accounts/${id}/following` const MASTODON_FOLLOWING_URL = id => `/api/v1/accounts/${id}/following`
const MASTODON_FOLLOWERS_URL = id => `/api/v1/accounts/${id}/followers` const MASTODON_FOLLOWERS_URL = id => `/api/v1/accounts/${id}/followers`
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_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 +62,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
@ -211,16 +227,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 +257,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,7 +274,6 @@ const fetchUser = ({id, credentials}) => {
return resolve(json) return resolve(json)
})) }))
}) })
.then((data) => parseUser(data))
} }
const fetchFriends = ({id, page, credentials}) => { const fetchFriends = ({id, page, credentials}) => {
@ -298,8 +317,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 +326,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,23 +345,7 @@ 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: PUBLIC_TIMELINE_URL,
friends: FRIENDS_TIMELINE_URL, friends: FRIENDS_TIMELINE_URL,
@ -347,8 +353,8 @@ const fetchTimeline = ({timeline, credentials, since = false, until = false, use
dms: DM_TIMELINE_URL, dms: DM_TIMELINE_URL,
notifications: QVITTER_USER_NOTIFICATIONS_URL, notifications: QVITTER_USER_NOTIFICATIONS_URL,
'publicAndExternal': PUBLIC_AND_EXTERNAL_TIMELINE_URL, 'publicAndExternal': PUBLIC_AND_EXTERNAL_TIMELINE_URL,
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: TAG_TIMELINE_URL
} }
@ -357,15 +363,16 @@ const fetchTimeline = ({timeline, credentials, since = false, until = false, use
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 += `/${tag}.json`
} }
@ -374,6 +381,7 @@ const fetchTimeline = ({timeline, credentials, since = false, until = false, use
} }
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}`
@ -434,23 +442,23 @@ const unretweet = ({ id, credentials }) => {
}) })
} }
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)
@ -475,13 +483,13 @@ const deleteStatus = ({ id, credentials }) => {
} }
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 +530,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 +606,7 @@ const apiService = {
blockUser, blockUser,
unblockUser, unblockUser,
fetchUser, fetchUser,
fetchUserRelationship,
favorite, favorite,
unfavorite, unfavorite,
retweet, retweet,
@ -596,8 +615,9 @@ const apiService = {
deleteStatus, deleteStatus,
uploadMedia, uploadMedia,
fetchAllFollowing, fetchAllFollowing,
setUserMute,
fetchMutes, fetchMutes,
muteUser,
unmuteUser,
fetchBlocks, fetchBlocks,
fetchOAuthTokens, fetchOAuthTokens,
revokeOAuthToken, revokeOAuthToken,

View File

@ -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,7 +170,7 @@ 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
@ -166,7 +179,7 @@ export const parseStatus = (data) => {
output.in_reply_to_user_id = data.in_reply_to_account_id output.in_reply_to_user_id = data.in_reply_to_account_id
// 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 +189,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 +271,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 +294,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

@ -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,6 +19,9 @@ 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.minId args['until'] = until || timelineData.minId
@ -28,6 +31,7 @@ const fetchAndUpdate = ({store, credentials, timeline = 'friends', older = false
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

@ -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;'); }

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

@ -34,6 +34,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;'); }

15
static/font/css/fontello.css vendored Normal file → Executable file
View File

@ -1,11 +1,11 @@
@font-face { @font-face {
font-family: 'fontello'; font-family: 'fontello';
src: url('../font/fontello.eot?94672585'); src: url('../font/fontello.eot?40679575');
src: url('../font/fontello.eot?94672585#iefix') format('embedded-opentype'), src: url('../font/fontello.eot?40679575#iefix') format('embedded-opentype'),
url('../font/fontello.woff2?94672585') format('woff2'), url('../font/fontello.woff2?40679575') format('woff2'),
url('../font/fontello.woff?94672585') format('woff'), url('../font/fontello.woff?40679575') format('woff'),
url('../font/fontello.ttf?94672585') format('truetype'), url('../font/fontello.ttf?40679575') format('truetype'),
url('../font/fontello.svg?94672585#fontello') format('svg'); url('../font/fontello.svg?40679575#fontello') format('svg');
font-weight: normal; font-weight: normal;
font-style: normal; font-style: normal;
} }
@ -15,7 +15,7 @@
@media screen and (-webkit-min-device-pixel-ratio:0) { @media screen and (-webkit-min-device-pixel-ratio:0) {
@font-face { @font-face {
font-family: 'fontello'; font-family: 'fontello';
src: url('../font/fontello.svg?94672585#fontello') format('svg'); src: url('../font/fontello.svg?40679575#fontello') format('svg');
} }
} }
*/ */
@ -79,6 +79,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'; } /* '' */

17
static/font/demo.html Normal file → Executable file
View File

@ -229,11 +229,11 @@ body {
} }
@font-face { @font-face {
font-family: 'fontello'; font-family: 'fontello';
src: url('./font/fontello.eot?28736547'); src: url('./font/fontello.eot?50378338');
src: url('./font/fontello.eot?28736547#iefix') format('embedded-opentype'), src: url('./font/fontello.eot?50378338#iefix') format('embedded-opentype'),
url('./font/fontello.woff?28736547') format('woff'), url('./font/fontello.woff?50378338') format('woff'),
url('./font/fontello.ttf?28736547') format('truetype'), url('./font/fontello.ttf?50378338') format('truetype'),
url('./font/fontello.svg?28736547#fontello') format('svg'); url('./font/fontello.svg?50378338#fontello') format('svg');
font-weight: normal; font-weight: normal;
font-style: normal; font-style: normal;
} }
@ -334,24 +334,25 @@ body {
<div class="the-icons span3" title="Code: 0xe817"><i class="demo-icon icon-edit">&#xe817;</i> <span class="i-name">icon-edit</span><span class="i-code">0xe817</span></div> <div class="the-icons span3" title="Code: 0xe817"><i class="demo-icon icon-edit">&#xe817;</i> <span class="i-name">icon-edit</span><span class="i-code">0xe817</span></div>
</div> </div>
<div class="row"> <div class="row">
<div class="the-icons span3" title="Code: 0xe818"><i class="demo-icon icon-pencil">&#xe818;</i> <span class="i-name">icon-pencil</span><span class="i-code">0xe818</span></div>
<div class="the-icons span3" title="Code: 0xe832"><i class="demo-icon icon-spin3 animate-spin">&#xe832;</i> <span class="i-name">icon-spin3</span><span class="i-code">0xe832</span></div> <div class="the-icons span3" title="Code: 0xe832"><i class="demo-icon icon-spin3 animate-spin">&#xe832;</i> <span class="i-name">icon-spin3</span><span class="i-code">0xe832</span></div>
<div class="the-icons span3" title="Code: 0xe834"><i class="demo-icon icon-spin4 animate-spin">&#xe834;</i> <span class="i-name">icon-spin4</span><span class="i-code">0xe834</span></div> <div class="the-icons span3" title="Code: 0xe834"><i class="demo-icon icon-spin4 animate-spin">&#xe834;</i> <span class="i-name">icon-spin4</span><span class="i-code">0xe834</span></div>
<div class="the-icons span3" title="Code: 0xf08e"><i class="demo-icon icon-link-ext">&#xf08e;</i> <span class="i-name">icon-link-ext</span><span class="i-code">0xf08e</span></div> <div class="the-icons span3" title="Code: 0xf08e"><i class="demo-icon icon-link-ext">&#xf08e;</i> <span class="i-name">icon-link-ext</span><span class="i-code">0xf08e</span></div>
<div class="the-icons span3" title="Code: 0xf08f"><i class="demo-icon icon-link-ext-alt">&#xf08f;</i> <span class="i-name">icon-link-ext-alt</span><span class="i-code">0xf08f</span></div>
</div> </div>
<div class="row"> <div class="row">
<div class="the-icons span3" title="Code: 0xf08f"><i class="demo-icon icon-link-ext-alt">&#xf08f;</i> <span class="i-name">icon-link-ext-alt</span><span class="i-code">0xf08f</span></div>
<div class="the-icons span3" title="Code: 0xf0c9"><i class="demo-icon icon-menu">&#xf0c9;</i> <span class="i-name">icon-menu</span><span class="i-code">0xf0c9</span></div> <div class="the-icons span3" title="Code: 0xf0c9"><i class="demo-icon icon-menu">&#xf0c9;</i> <span class="i-name">icon-menu</span><span class="i-code">0xf0c9</span></div>
<div class="the-icons span3" title="Code: 0xf0e0"><i class="demo-icon icon-mail-alt">&#xf0e0;</i> <span class="i-name">icon-mail-alt</span><span class="i-code">0xf0e0</span></div> <div class="the-icons span3" title="Code: 0xf0e0"><i class="demo-icon icon-mail-alt">&#xf0e0;</i> <span class="i-name">icon-mail-alt</span><span class="i-code">0xf0e0</span></div>
<div class="the-icons span3" title="Code: 0xf0e5"><i class="demo-icon icon-comment-empty">&#xf0e5;</i> <span class="i-name">icon-comment-empty</span><span class="i-code">0xf0e5</span></div> <div class="the-icons span3" title="Code: 0xf0e5"><i class="demo-icon icon-comment-empty">&#xf0e5;</i> <span class="i-name">icon-comment-empty</span><span class="i-code">0xf0e5</span></div>
<div class="the-icons span3" title="Code: 0xf0fe"><i class="demo-icon icon-plus-squared">&#xf0fe;</i> <span class="i-name">icon-plus-squared</span><span class="i-code">0xf0fe</span></div>
</div> </div>
<div class="row"> <div class="row">
<div class="the-icons span3" title="Code: 0xf0fe"><i class="demo-icon icon-plus-squared">&#xf0fe;</i> <span class="i-name">icon-plus-squared</span><span class="i-code">0xf0fe</span></div>
<div class="the-icons span3" title="Code: 0xf112"><i class="demo-icon icon-reply">&#xf112;</i> <span class="i-name">icon-reply</span><span class="i-code">0xf112</span></div> <div class="the-icons span3" title="Code: 0xf112"><i class="demo-icon icon-reply">&#xf112;</i> <span class="i-name">icon-reply</span><span class="i-code">0xf112</span></div>
<div class="the-icons span3" title="Code: 0xf13e"><i class="demo-icon icon-lock-open-alt">&#xf13e;</i> <span class="i-name">icon-lock-open-alt</span><span class="i-code">0xf13e</span></div> <div class="the-icons span3" title="Code: 0xf13e"><i class="demo-icon icon-lock-open-alt">&#xf13e;</i> <span class="i-name">icon-lock-open-alt</span><span class="i-code">0xf13e</span></div>
<div class="the-icons span3" title="Code: 0xf144"><i class="demo-icon icon-play-circled">&#xf144;</i> <span class="i-name">icon-play-circled</span><span class="i-code">0xf144</span></div> <div class="the-icons span3" title="Code: 0xf144"><i class="demo-icon icon-play-circled">&#xf144;</i> <span class="i-name">icon-play-circled</span><span class="i-code">0xf144</span></div>
<div class="the-icons span3" title="Code: 0xf164"><i class="demo-icon icon-thumbs-up-alt">&#xf164;</i> <span class="i-name">icon-thumbs-up-alt</span><span class="i-code">0xf164</span></div>
</div> </div>
<div class="row"> <div class="row">
<div class="the-icons span3" title="Code: 0xf164"><i class="demo-icon icon-thumbs-up-alt">&#xf164;</i> <span class="i-name">icon-thumbs-up-alt</span><span class="i-code">0xf164</span></div>
<div class="the-icons span3" title="Code: 0xf1e5"><i class="demo-icon icon-binoculars">&#xf1e5;</i> <span class="i-name">icon-binoculars</span><span class="i-code">0xf1e5</span></div> <div class="the-icons span3" title="Code: 0xf1e5"><i class="demo-icon icon-binoculars">&#xf1e5;</i> <span class="i-name">icon-binoculars</span><span class="i-code">0xf1e5</span></div>
<div class="the-icons span3" title="Code: 0xf234"><i class="demo-icon icon-user-plus">&#xf234;</i> <span class="i-name">icon-user-plus</span><span class="i-code">0xf234</span></div> <div class="the-icons span3" title="Code: 0xf234"><i class="demo-icon icon-user-plus">&#xf234;</i> <span class="i-name">icon-user-plus</span><span class="i-code">0xf234</span></div>
</div> </div>

BIN
static/font/font/fontello.eot Normal file → Executable file

Binary file not shown.

2
static/font/font/fontello.svg Normal file → Executable file
View File

@ -54,6 +54,8 @@
<glyph glyph-name="edit" unicode="&#xe817;" d="M496 196l64 65-85 85-64-65v-31h53v-54h32z m245 402q-9 9-18 0l-196-196q-9-9 0-18t18 0l196 196q9 9 0 18z m45-331v-106q0-67-47-114t-114-47h-464q-67 0-114 47t-47 114v464q0 66 47 113t114 48h464q35 0 65-14 9-4 10-13 2-10-5-16l-27-28q-8-8-18-4-13 3-25 3h-464q-37 0-63-26t-27-63v-464q0-37 27-63t63-27h464q37 0 63 27t26 63v70q0 7 5 12l36 36q8 8 20 4t11-16z m-54 411l161-160-375-375h-161v160z m248-73l-51-52-161 161 51 52q16 15 38 15t38-15l85-85q16-16 16-38t-16-38z" horiz-adv-x="1000" /> <glyph glyph-name="edit" unicode="&#xe817;" d="M496 196l64 65-85 85-64-65v-31h53v-54h32z m245 402q-9 9-18 0l-196-196q-9-9 0-18t18 0l196 196q9 9 0 18z m45-331v-106q0-67-47-114t-114-47h-464q-67 0-114 47t-47 114v464q0 66 47 113t114 48h464q35 0 65-14 9-4 10-13 2-10-5-16l-27-28q-8-8-18-4-13 3-25 3h-464q-37 0-63-26t-27-63v-464q0-37 27-63t63-27h464q37 0 63 27t26 63v70q0 7 5 12l36 36q8 8 20 4t11-16z m-54 411l161-160-375-375h-161v160z m248-73l-51-52-161 161 51 52q16 15 38 15t38-15l85-85q16-16 16-38t-16-38z" horiz-adv-x="1000" />
<glyph glyph-name="pencil" unicode="&#xe818;" d="M203 0l50 51-131 131-51-51v-60h72v-71h60z m291 518q0 12-12 12-5 0-9-4l-303-302q-4-4-4-10 0-12 13-12 5 0 9 4l303 302q3 4 3 10z m-30 107l232-232-464-465h-232v233z m381-54q0-29-20-50l-93-93-232 233 93 92q20 21 50 21 29 0 51-21l131-131q20-22 20-51z" horiz-adv-x="857.1" />
<glyph glyph-name="spin3" unicode="&#xe832;" d="M494 857c-266 0-483-210-494-472-1-19 13-20 13-20l84 0c16 0 19 10 19 18 10 199 176 358 378 358 107 0 205-45 273-118l-58-57c-11-12-11-27 5-31l247-50c21-5 46 11 37 44l-58 227c-2 9-16 22-29 13l-65-60c-89 91-214 148-352 148z m409-508c-16 0-19-10-19-18-10-199-176-358-377-358-108 0-205 45-274 118l59 57c10 12 10 27-5 31l-248 50c-21 5-46-11-37-44l58-227c2-9 16-22 30-13l64 60c89-91 214-148 353-148 265 0 482 210 493 473 1 18-13 19-13 19l-84 0z" horiz-adv-x="1000" /> <glyph glyph-name="spin3" unicode="&#xe832;" d="M494 857c-266 0-483-210-494-472-1-19 13-20 13-20l84 0c16 0 19 10 19 18 10 199 176 358 378 358 107 0 205-45 273-118l-58-57c-11-12-11-27 5-31l247-50c21-5 46 11 37 44l-58 227c-2 9-16 22-29 13l-65-60c-89 91-214 148-352 148z m409-508c-16 0-19-10-19-18-10-199-176-358-377-358-108 0-205 45-274 118l59 57c10 12 10 27-5 31l-248 50c-21 5-46-11-37-44l58-227c2-9 16-22 30-13l64 60c89-91 214-148 353-148 265 0 482 210 493 473 1 18-13 19-13 19l-84 0z" horiz-adv-x="1000" />
<glyph glyph-name="spin4" unicode="&#xe834;" d="M498 857c-114 0-228-39-320-116l0 0c173 140 428 130 588-31 134-134 164-332 89-495-10-29-5-50 12-68 21-20 61-23 84 0 3 3 12 15 15 24 71 180 33 393-112 539-99 98-228 147-356 147z m-409-274c-14 0-29-5-39-16-3-3-13-15-15-24-71-180-34-393 112-539 185-185 479-195 676-31l0 0c-173-140-428-130-589 31-134 134-163 333-89 495 11 29 6 50-12 68-11 11-27 17-44 16z" horiz-adv-x="1001" /> <glyph glyph-name="spin4" unicode="&#xe834;" d="M498 857c-114 0-228-39-320-116l0 0c173 140 428 130 588-31 134-134 164-332 89-495-10-29-5-50 12-68 21-20 61-23 84 0 3 3 12 15 15 24 71 180 33 393-112 539-99 98-228 147-356 147z m-409-274c-14 0-29-5-39-16-3-3-13-15-15-24-71-180-34-393 112-539 185-185 479-195 676-31l0 0c-173-140-428-130-589 31-134 134-163 333-89 495 11 29 6 50-12 68-11 11-27 17-44 16z" horiz-adv-x="1001" />

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

BIN
static/font/font/fontello.ttf Normal file → Executable file

Binary file not shown.

BIN
static/font/font/fontello.woff Normal file → Executable file

Binary file not shown.

BIN
static/font/font/fontello.woff2 Normal file → Executable file

Binary file not shown.

View File

@ -12,9 +12,13 @@ const mutations = {
setError: () => {} setError: () => {}
} }
const actions = {
fetchUser: () => {},
fetchUserByScreenName: () => {}
}
const testGetters = { const testGetters = {
userByName: state => getters.userByName(state.users), findUser: state => getters.findUser(state.users)
userById: state => getters.userById(state.users)
} }
const localUser = { const localUser = {
@ -31,6 +35,7 @@ const extUser = {
const externalProfileStore = new Vuex.Store({ const externalProfileStore = new Vuex.Store({
mutations, mutations,
actions,
getters: testGetters, getters: testGetters,
state: { state: {
api: { api: {
@ -89,7 +94,7 @@ const externalProfileStore = new Vuex.Store({
currentUser: { currentUser: {
credentials: '' credentials: ''
}, },
usersObject: [extUser], usersObject: { 100: extUser },
users: [extUser] users: [extUser]
} }
} }
@ -97,6 +102,7 @@ const externalProfileStore = new Vuex.Store({
const localProfileStore = new Vuex.Store({ const localProfileStore = new Vuex.Store({
mutations, mutations,
actions,
getters: testGetters, getters: testGetters,
state: { state: {
api: { api: {
@ -155,7 +161,7 @@ const localProfileStore = new Vuex.Store({
currentUser: { currentUser: {
credentials: '' credentials: ''
}, },
usersObject: [localUser], usersObject: { 100: localUser, 'testuser': localUser },
users: [localUser] users: [localUser]
} }
} }

View File

@ -14,238 +14,258 @@ const makeMockStatus = ({id, text, type = 'status'}) => {
} }
} }
describe('Statuses.prepareStatus', () => { describe('Statuses module', () => {
it('sets deleted flag to false', () => { describe('prepareStatus', () => {
const aStatus = makeMockStatus({id: '1', text: 'Hello oniichan'}) it('sets deleted flag to false', () => {
expect(prepareStatus(aStatus).deleted).to.eq(false) const aStatus = makeMockStatus({id: '1', text: 'Hello oniichan'})
}) expect(prepareStatus(aStatus).deleted).to.eq(false)
}) })
describe('The Statuses module', () => {
it('adds the status to allStatuses and to the given timeline', () => {
const state = defaultState()
const status = makeMockStatus({id: '1'})
mutations.addNewStatuses(state, { statuses: [status], timeline: 'public' })
expect(state.allStatuses).to.eql([status])
expect(state.timelines.public.statuses).to.eql([status])
expect(state.timelines.public.visibleStatuses).to.eql([])
expect(state.timelines.public.newStatusCount).to.equal(1)
}) })
it('counts the status as new if it has not been seen on this timeline', () => { describe('addNewStatuses', () => {
const state = defaultState() it('adds the status to allStatuses and to the given timeline', () => {
const status = makeMockStatus({id: '1'}) const state = defaultState()
const status = makeMockStatus({id: '1'})
mutations.addNewStatuses(state, { statuses: [status], timeline: 'public' }) mutations.addNewStatuses(state, { statuses: [status], timeline: 'public' })
mutations.addNewStatuses(state, { statuses: [status], timeline: 'friends' })
expect(state.allStatuses).to.eql([status]) expect(state.allStatuses).to.eql([status])
expect(state.timelines.public.statuses).to.eql([status]) expect(state.timelines.public.statuses).to.eql([status])
expect(state.timelines.public.visibleStatuses).to.eql([]) expect(state.timelines.public.visibleStatuses).to.eql([])
expect(state.timelines.public.newStatusCount).to.equal(1) expect(state.timelines.public.newStatusCount).to.equal(1)
})
expect(state.allStatuses).to.eql([status]) it('counts the status as new if it has not been seen on this timeline', () => {
expect(state.timelines.friends.statuses).to.eql([status]) const state = defaultState()
expect(state.timelines.friends.visibleStatuses).to.eql([]) const status = makeMockStatus({id: '1'})
expect(state.timelines.friends.newStatusCount).to.equal(1)
mutations.addNewStatuses(state, { statuses: [status], timeline: 'public' })
mutations.addNewStatuses(state, { statuses: [status], timeline: 'friends' })
expect(state.allStatuses).to.eql([status])
expect(state.timelines.public.statuses).to.eql([status])
expect(state.timelines.public.visibleStatuses).to.eql([])
expect(state.timelines.public.newStatusCount).to.equal(1)
expect(state.allStatuses).to.eql([status])
expect(state.timelines.friends.statuses).to.eql([status])
expect(state.timelines.friends.visibleStatuses).to.eql([])
expect(state.timelines.friends.newStatusCount).to.equal(1)
})
it('add the statuses to allStatuses if no timeline is given', () => {
const state = defaultState()
const status = makeMockStatus({id: '1'})
mutations.addNewStatuses(state, { statuses: [status] })
expect(state.allStatuses).to.eql([status])
expect(state.timelines.public.statuses).to.eql([])
expect(state.timelines.public.visibleStatuses).to.eql([])
expect(state.timelines.public.newStatusCount).to.equal(0)
})
it('adds the status to allStatuses and to the given timeline, directly visible', () => {
const state = defaultState()
const status = makeMockStatus({id: '1'})
mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' })
expect(state.allStatuses).to.eql([status])
expect(state.timelines.public.statuses).to.eql([status])
expect(state.timelines.public.visibleStatuses).to.eql([status])
expect(state.timelines.public.newStatusCount).to.equal(0)
})
it('removes statuses by tag on deletion', () => {
const state = defaultState()
const status = makeMockStatus({id: '1'})
const otherStatus = makeMockStatus({id: '3'})
status.uri = 'xxx'
const deletion = makeMockStatus({id: '2', type: 'deletion'})
deletion.text = 'Dolus deleted notice {{tag:gs.smuglo.li,2016-11-18:noticeId=1038007:objectType=note}}.'
deletion.uri = 'xxx'
mutations.addNewStatuses(state, { statuses: [status, otherStatus], showImmediately: true, timeline: 'public' })
mutations.addNewStatuses(state, { statuses: [deletion], showImmediately: true, timeline: 'public' })
expect(state.allStatuses).to.eql([otherStatus])
expect(state.timelines.public.statuses).to.eql([otherStatus])
expect(state.timelines.public.visibleStatuses).to.eql([otherStatus])
expect(state.timelines.public.maxId).to.eql('3')
})
it('does not update the maxId when the noIdUpdate flag is set', () => {
const state = defaultState()
const status = makeMockStatus({id: '1'})
const secondStatus = makeMockStatus({id: '2'})
mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' })
expect(state.timelines.public.maxId).to.eql('1')
mutations.addNewStatuses(state, { statuses: [secondStatus], showImmediately: true, timeline: 'public', noIdUpdate: true })
expect(state.timelines.public.statuses).to.eql([secondStatus, status])
expect(state.timelines.public.visibleStatuses).to.eql([secondStatus, status])
expect(state.timelines.public.maxId).to.eql('1')
})
it('keeps a descending by id order in timeline.visibleStatuses and timeline.statuses', () => {
const state = defaultState()
const nonVisibleStatus = makeMockStatus({id: '1'})
const status = makeMockStatus({id: '3'})
const statusTwo = makeMockStatus({id: '2'})
const statusThree = makeMockStatus({id: '4'})
mutations.addNewStatuses(state, { statuses: [nonVisibleStatus], showImmediately: false, timeline: 'public' })
mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' })
mutations.addNewStatuses(state, { statuses: [statusTwo], showImmediately: true, timeline: 'public' })
expect(state.timelines.public.minVisibleId).to.equal('2')
mutations.addNewStatuses(state, { statuses: [statusThree], showImmediately: true, timeline: 'public' })
expect(state.timelines.public.statuses).to.eql([statusThree, status, statusTwo, nonVisibleStatus])
expect(state.timelines.public.visibleStatuses).to.eql([statusThree, status, statusTwo])
})
it('splits retweets from their status and links them', () => {
const state = defaultState()
const status = makeMockStatus({id: '1'})
const retweet = makeMockStatus({id: '2', type: 'retweet'})
const modStatus = makeMockStatus({id: '1', text: 'something else'})
retweet.retweeted_status = status
// It adds both statuses, but only the retweet to visible.
mutations.addNewStatuses(state, { statuses: [retweet], timeline: 'public', showImmediately: true })
expect(state.timelines.public.visibleStatuses).to.have.length(1)
expect(state.timelines.public.statuses).to.have.length(1)
expect(state.allStatuses).to.have.length(2)
expect(state.allStatuses[0].id).to.equal('1')
expect(state.allStatuses[1].id).to.equal('2')
// It refers to the modified status.
mutations.addNewStatuses(state, { statuses: [modStatus], timeline: 'public' })
expect(state.allStatuses).to.have.length(2)
expect(state.allStatuses[0].id).to.equal('1')
expect(state.allStatuses[0].text).to.equal(modStatus.text)
expect(state.allStatuses[1].id).to.equal('2')
expect(retweet.retweeted_status.text).to.eql(modStatus.text)
})
it('replaces existing statuses with the same id', () => {
const state = defaultState()
const status = makeMockStatus({id: '1'})
const modStatus = makeMockStatus({id: '1', text: 'something else'})
// Add original status
mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' })
expect(state.timelines.public.visibleStatuses).to.have.length(1)
expect(state.allStatuses).to.have.length(1)
// Add new version of status
mutations.addNewStatuses(state, { statuses: [modStatus], showImmediately: true, timeline: 'public' })
expect(state.timelines.public.visibleStatuses).to.have.length(1)
expect(state.allStatuses).to.have.length(1)
expect(state.allStatuses[0].text).to.eql(modStatus.text)
})
it('replaces existing statuses with the same id, coming from a retweet', () => {
const state = defaultState()
const status = makeMockStatus({id: '1'})
const modStatus = makeMockStatus({id: '1', text: 'something else'})
const retweet = makeMockStatus({id: '2', type: 'retweet'})
retweet.retweeted_status = modStatus
// Add original status
mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' })
expect(state.timelines.public.visibleStatuses).to.have.length(1)
expect(state.allStatuses).to.have.length(1)
// Add new version of status
mutations.addNewStatuses(state, { statuses: [retweet], showImmediately: false, timeline: 'public' })
expect(state.timelines.public.visibleStatuses).to.have.length(1)
// Don't add the retweet itself if the tweet is visible
expect(state.timelines.public.statuses).to.have.length(1)
expect(state.allStatuses).to.have.length(2)
expect(state.allStatuses[0].text).to.eql(modStatus.text)
})
it('handles favorite actions', () => {
const state = defaultState()
const status = makeMockStatus({id: '1'})
const favorite = {
id: '2',
type: 'favorite',
in_reply_to_status_id: '1', // The API uses strings here...
uri: 'tag:shitposter.club,2016-08-21:fave:3895:note:773501:2016-08-21T16:52:15+00:00',
text: 'a favorited something by b',
user: { id: '99' }
}
mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' })
mutations.addNewStatuses(state, { statuses: [favorite], showImmediately: true, timeline: 'public' })
expect(state.timelines.public.visibleStatuses.length).to.eql(1)
expect(state.timelines.public.visibleStatuses[0].fave_num).to.eql(1)
expect(state.timelines.public.maxId).to.eq(favorite.id)
// Adding it again does nothing
mutations.addNewStatuses(state, { statuses: [favorite], showImmediately: true, timeline: 'public' })
expect(state.timelines.public.visibleStatuses.length).to.eql(1)
expect(state.timelines.public.visibleStatuses[0].fave_num).to.eql(1)
expect(state.timelines.public.maxId).to.eq(favorite.id)
// If something is favorited by the current user, it also sets the 'favorited' property but does not increment counter to avoid over-counting. Counter is incremented (updated, really) via response to the favorite request.
const user = {
id: '1'
}
const ownFavorite = {
id: '3',
type: 'favorite',
in_reply_to_status_id: '1', // The API uses strings here...
uri: 'tag:shitposter.club,2016-08-21:fave:3895:note:773501:2016-08-21T16:52:15+00:00',
text: 'a favorited something by b',
user
}
mutations.addNewStatuses(state, { statuses: [ownFavorite], showImmediately: true, timeline: 'public', user })
expect(state.timelines.public.visibleStatuses.length).to.eql(1)
expect(state.timelines.public.visibleStatuses[0].fave_num).to.eql(1)
expect(state.timelines.public.visibleStatuses[0].favorited).to.eql(true)
})
}) })
it('add the statuses to allStatuses if no timeline is given', () => { describe('showNewStatuses', () => {
const state = defaultState() it('resets the minId to the min of the visible statuses when adding new to visible statuses', () => {
const status = makeMockStatus({id: '1'}) const state = defaultState()
const status = makeMockStatus({ id: '10' })
mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' })
const newStatus = makeMockStatus({ id: '20' })
mutations.addNewStatuses(state, { statuses: [newStatus], showImmediately: false, timeline: 'public' })
state.timelines.public.minId = '5'
mutations.showNewStatuses(state, { timeline: 'public' })
mutations.addNewStatuses(state, { statuses: [status] }) expect(state.timelines.public.visibleStatuses.length).to.eql(2)
expect(state.timelines.public.minVisibleId).to.eql('10')
expect(state.allStatuses).to.eql([status]) expect(state.timelines.public.minId).to.eql('10')
expect(state.timelines.public.statuses).to.eql([]) })
expect(state.timelines.public.visibleStatuses).to.eql([])
expect(state.timelines.public.newStatusCount).to.equal(0)
}) })
it('adds the status to allStatuses and to the given timeline, directly visible', () => { describe('clearTimeline', () => {
const state = defaultState() it('keeps userId when clearing user timeline', () => {
const status = makeMockStatus({id: '1'}) const state = defaultState()
state.timelines.user.userId = 123
mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' }) mutations.clearTimeline(state, { timeline: 'user' })
expect(state.allStatuses).to.eql([status]) expect(state.timelines.user.userId).to.eql(123)
expect(state.timelines.public.statuses).to.eql([status]) })
expect(state.timelines.public.visibleStatuses).to.eql([status])
expect(state.timelines.public.newStatusCount).to.equal(0)
})
it('removes statuses by tag on deletion', () => {
const state = defaultState()
const status = makeMockStatus({id: '1'})
const otherStatus = makeMockStatus({id: '3'})
status.uri = 'xxx'
const deletion = makeMockStatus({id: '2', type: 'deletion'})
deletion.text = 'Dolus deleted notice {{tag:gs.smuglo.li,2016-11-18:noticeId=1038007:objectType=note}}.'
deletion.uri = 'xxx'
mutations.addNewStatuses(state, { statuses: [status, otherStatus], showImmediately: true, timeline: 'public' })
mutations.addNewStatuses(state, { statuses: [deletion], showImmediately: true, timeline: 'public' })
expect(state.allStatuses).to.eql([otherStatus])
expect(state.timelines.public.statuses).to.eql([otherStatus])
expect(state.timelines.public.visibleStatuses).to.eql([otherStatus])
expect(state.timelines.public.maxId).to.eql('3')
})
it('does not update the maxId when the noIdUpdate flag is set', () => {
const state = defaultState()
const status = makeMockStatus({id: '1'})
const secondStatus = makeMockStatus({id: '2'})
mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' })
expect(state.timelines.public.maxId).to.eql('1')
mutations.addNewStatuses(state, { statuses: [secondStatus], showImmediately: true, timeline: 'public', noIdUpdate: true })
expect(state.timelines.public.statuses).to.eql([secondStatus, status])
expect(state.timelines.public.visibleStatuses).to.eql([secondStatus, status])
expect(state.timelines.public.maxId).to.eql('1')
})
it('keeps a descending by id order in timeline.visibleStatuses and timeline.statuses', () => {
const state = defaultState()
const nonVisibleStatus = makeMockStatus({id: '1'})
const status = makeMockStatus({id: '3'})
const statusTwo = makeMockStatus({id: '2'})
const statusThree = makeMockStatus({id: '4'})
mutations.addNewStatuses(state, { statuses: [nonVisibleStatus], showImmediately: false, timeline: 'public' })
mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' })
mutations.addNewStatuses(state, { statuses: [statusTwo], showImmediately: true, timeline: 'public' })
expect(state.timelines.public.minVisibleId).to.equal('2')
mutations.addNewStatuses(state, { statuses: [statusThree], showImmediately: true, timeline: 'public' })
expect(state.timelines.public.statuses).to.eql([statusThree, status, statusTwo, nonVisibleStatus])
expect(state.timelines.public.visibleStatuses).to.eql([statusThree, status, statusTwo])
})
it('splits retweets from their status and links them', () => {
const state = defaultState()
const status = makeMockStatus({id: '1'})
const retweet = makeMockStatus({id: '2', type: 'retweet'})
const modStatus = makeMockStatus({id: '1', text: 'something else'})
retweet.retweeted_status = status
// It adds both statuses, but only the retweet to visible.
mutations.addNewStatuses(state, { statuses: [retweet], timeline: 'public', showImmediately: true })
expect(state.timelines.public.visibleStatuses).to.have.length(1)
expect(state.timelines.public.statuses).to.have.length(1)
expect(state.allStatuses).to.have.length(2)
expect(state.allStatuses[0].id).to.equal('1')
expect(state.allStatuses[1].id).to.equal('2')
// It refers to the modified status.
mutations.addNewStatuses(state, { statuses: [modStatus], timeline: 'public' })
expect(state.allStatuses).to.have.length(2)
expect(state.allStatuses[0].id).to.equal('1')
expect(state.allStatuses[0].text).to.equal(modStatus.text)
expect(state.allStatuses[1].id).to.equal('2')
expect(retweet.retweeted_status.text).to.eql(modStatus.text)
})
it('replaces existing statuses with the same id', () => {
const state = defaultState()
const status = makeMockStatus({id: '1'})
const modStatus = makeMockStatus({id: '1', text: 'something else'})
// Add original status
mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' })
expect(state.timelines.public.visibleStatuses).to.have.length(1)
expect(state.allStatuses).to.have.length(1)
// Add new version of status
mutations.addNewStatuses(state, { statuses: [modStatus], showImmediately: true, timeline: 'public' })
expect(state.timelines.public.visibleStatuses).to.have.length(1)
expect(state.allStatuses).to.have.length(1)
expect(state.allStatuses[0].text).to.eql(modStatus.text)
})
it('replaces existing statuses with the same id, coming from a retweet', () => {
const state = defaultState()
const status = makeMockStatus({id: '1'})
const modStatus = makeMockStatus({id: '1', text: 'something else'})
const retweet = makeMockStatus({id: '2', type: 'retweet'})
retweet.retweeted_status = modStatus
// Add original status
mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' })
expect(state.timelines.public.visibleStatuses).to.have.length(1)
expect(state.allStatuses).to.have.length(1)
// Add new version of status
mutations.addNewStatuses(state, { statuses: [retweet], showImmediately: false, timeline: 'public' })
expect(state.timelines.public.visibleStatuses).to.have.length(1)
// Don't add the retweet itself if the tweet is visible
expect(state.timelines.public.statuses).to.have.length(1)
expect(state.allStatuses).to.have.length(2)
expect(state.allStatuses[0].text).to.eql(modStatus.text)
})
it('handles favorite actions', () => {
const state = defaultState()
const status = makeMockStatus({id: '1'})
const favorite = {
id: '2',
type: 'favorite',
in_reply_to_status_id: '1', // The API uses strings here...
uri: 'tag:shitposter.club,2016-08-21:fave:3895:note:773501:2016-08-21T16:52:15+00:00',
text: 'a favorited something by b',
user: { id: '99' }
}
mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' })
mutations.addNewStatuses(state, { statuses: [favorite], showImmediately: true, timeline: 'public' })
expect(state.timelines.public.visibleStatuses.length).to.eql(1)
expect(state.timelines.public.visibleStatuses[0].fave_num).to.eql(1)
expect(state.timelines.public.maxId).to.eq(favorite.id)
// Adding it again does nothing
mutations.addNewStatuses(state, { statuses: [favorite], showImmediately: true, timeline: 'public' })
expect(state.timelines.public.visibleStatuses.length).to.eql(1)
expect(state.timelines.public.visibleStatuses[0].fave_num).to.eql(1)
expect(state.timelines.public.maxId).to.eq(favorite.id)
// If something is favorited by the current user, it also sets the 'favorited' property but does not increment counter to avoid over-counting. Counter is incremented (updated, really) via response to the favorite request.
const user = {
id: '1'
}
const ownFavorite = {
id: '3',
type: 'favorite',
in_reply_to_status_id: '1', // The API uses strings here...
uri: 'tag:shitposter.club,2016-08-21:fave:3895:note:773501:2016-08-21T16:52:15+00:00',
text: 'a favorited something by b',
user
}
mutations.addNewStatuses(state, { statuses: [ownFavorite], showImmediately: true, timeline: 'public', user })
expect(state.timelines.public.visibleStatuses.length).to.eql(1)
expect(state.timelines.public.visibleStatuses[0].fave_num).to.eql(1)
expect(state.timelines.public.visibleStatuses[0].favorited).to.eql(true)
})
it('keeps userId when clearing user timeline', () => {
const state = defaultState()
state.timelines.user.userId = 123
mutations.clearTimeline(state, { timeline: 'user' })
expect(state.timelines.user.userId).to.eql(123)
}) })
describe('notifications', () => { describe('notifications', () => {

View File

@ -34,40 +34,31 @@ describe('The users module', () => {
}) })
}) })
describe('getUserByName', () => { describe('findUser', () => {
it('returns user with matching screen_name', () => { it('returns user with matching screen_name', () => {
const user = { screen_name: 'Guy', id: '1' }
const state = { const state = {
users: [ usersObject: {
{ screen_name: 'Guy', id: '1' } 1: user,
] guy: user
}
} }
const name = 'Guy' const name = 'Guy'
const expected = { screen_name: 'Guy', id: '1' } const expected = { screen_name: 'Guy', id: '1' }
expect(getters.userByName(state)(name)).to.eql(expected) expect(getters.findUser(state)(name)).to.eql(expected)
}) })
it('returns user with matching screen_name with different case', () => {
const state = {
users: [
{ screen_name: 'guy', id: '1' }
]
}
const name = 'Guy'
const expected = { screen_name: 'guy', id: '1' }
expect(getters.userByName(state)(name)).to.eql(expected)
})
})
describe('getUserById', () => {
it('returns user with matching id', () => { it('returns user with matching id', () => {
const user = { screen_name: 'Guy', id: '1' }
const state = { const state = {
users: [ usersObject: {
{ screen_name: 'Guy', id: '1' } 1: user,
] guy: user
}
} }
const id = '1' const id = '1'
const expected = { screen_name: 'Guy', id: '1' } const expected = { screen_name: 'Guy', id: '1' }
expect(getters.userById(state)(id)).to.eql(expected) expect(getters.findUser(state)(id)).to.eql(expected)
}) })
}) })
}) })

View File

@ -1,4 +1,4 @@
import { parseStatus, parseUser, parseNotification } from '../../../../../src/services/entity_normalizer/entity_normalizer.service.js' import { parseStatus, parseUser, parseNotification, addEmojis } from '../../../../../src/services/entity_normalizer/entity_normalizer.service.js'
import mastoapidata from '../../../../fixtures/mastoapi.json' import mastoapidata from '../../../../fixtures/mastoapi.json'
import qvitterapidata from '../../../../fixtures/statuses.json' import qvitterapidata from '../../../../fixtures/statuses.json'
@ -143,6 +143,23 @@ const makeMockNotificationQvitter = (overrides = {}) => {
}, overrides) }, overrides)
} }
const makeMockEmojiMasto = (overrides = [{}]) => {
return [
Object.assign({
shortcode: 'image',
static_url: 'https://example.com/image.png',
url: 'https://example.com/image.png',
visible_in_picker: false
}, overrides[0]),
Object.assign({
shortcode: 'thinking',
static_url: 'https://example.com/think.png',
url: 'https://example.com/think.png',
visible_in_picker: false
}, overrides[1])
]
}
parseNotification parseNotification
parseUser parseUser
parseStatus parseStatus
@ -218,6 +235,22 @@ describe('API Entities normalizer', () => {
expect(parsedRepeat).to.have.property('retweeted_status') expect(parsedRepeat).to.have.property('retweeted_status')
expect(parsedRepeat).to.have.deep.property('retweeted_status.id', 'deadbeef') expect(parsedRepeat).to.have.deep.property('retweeted_status.id', 'deadbeef')
}) })
it('adds emojis to post content', () => {
const post = makeMockStatusMasto({ emojis: makeMockEmojiMasto(), content: 'Makes you think :thinking:' })
const parsedPost = parseStatus(post)
expect(parsedPost).to.have.property('statusnet_html').that.contains('<img')
})
it('adds emojis to subject line', () => {
const post = makeMockStatusMasto({ emojis: makeMockEmojiMasto(), spoiler_text: 'CW: 300 IQ :thinking:' })
const parsedPost = parseStatus(post)
expect(parsedPost).to.have.property('summary_html').that.contains('<img')
})
}) })
}) })
@ -230,6 +263,22 @@ describe('API Entities normalizer', () => {
expect(parseUser(local)).to.have.property('is_local', true) expect(parseUser(local)).to.have.property('is_local', true)
expect(parseUser(remote)).to.have.property('is_local', false) expect(parseUser(remote)).to.have.property('is_local', false)
}) })
it('adds emojis to user name', () => {
const user = makeMockUserMasto({ emojis: makeMockEmojiMasto(), display_name: 'The :thinking: thinker' })
const parsedUser = parseUser(user)
expect(parsedUser).to.have.property('name_html').that.contains('<img')
})
it('adds emojis to user bio', () => {
const user = makeMockUserMasto({ emojis: makeMockEmojiMasto(), note: 'Hello i like to :thinking: a lot' })
const parsedUser = parseUser(user)
expect(parsedUser).to.have.property('description_html').that.contains('<img')
})
}) })
// We currently use QvitterAPI notifications only, and especially due to MastoAPI lacking is_seen, support for MastoAPI // We currently use QvitterAPI notifications only, and especially due to MastoAPI lacking is_seen, support for MastoAPI
@ -267,4 +316,28 @@ describe('API Entities normalizer', () => {
expect(parseNotification(notif)).to.have.deep.property('from_profile.id', 'spurdo') expect(parseNotification(notif)).to.have.deep.property('from_profile.id', 'spurdo')
}) })
}) })
describe('MastoAPI emoji adder', () => {
const emojis = makeMockEmojiMasto()
const imageHtml = '<img src="https://example.com/image.png" alt="image" class="emoji" />'
.replace(/"/g, '\'')
const thinkHtml = '<img src="https://example.com/think.png" alt="thinking" class="emoji" />'
.replace(/"/g, '\'')
it('correctly replaces shortcodes in supplied string', () => {
const result = addEmojis('This post has :image: emoji and :thinking: emoji', emojis)
expect(result).to.include(thinkHtml)
expect(result).to.include(imageHtml)
})
it('handles consecutive emojis correctly', () => {
const result = addEmojis('Lelel emoji spam :thinking::thinking::thinking::thinking:', emojis)
expect(result).to.include(thinkHtml + thinkHtml + thinkHtml + thinkHtml)
})
it('Doesn\'t replace nonexistent emojis', () => {
const result = addEmojis('Admin add the :tenshi: emoji', emojis)
expect(result).to.equal('Admin add the :tenshi: emoji')
})
})
}) })