Merge remote-tracking branch 'upstream/develop' into shigusegubu

* upstream/develop: (32 commits)
  [Debug] Avoid duplicates in the who to follow panel
  updated German translation  * added theme settings  * added various missing single strings
  Fix translation typo in registration.vue
  Update README
  Fix profiles without statuses not loading
  Fix conflicting styles
  Remove commented out back button
  Cleanup and remove divider element in side drawer
  New routes, notifications, other impovements in side drwaer
  Add "noAttachmentLinks" to src/modules/instance.js
  Make "noAttachmentLinks" configurable
  No attachment links
  Treat reserved users like external users in the frontend.
  User Card Content fixes and updates
  scopeCopy → true by default
  Restore old routes, enable user route as fallback.
  improve web push notifications
  fix
  Update japanese translation
  fix inconsistencies within who_to_follow_panel
  ...
This commit is contained in:
Henry Jameson 2019-01-07 13:23:03 +03:00
commit 1eea45cf6d
57 changed files with 1255 additions and 470 deletions

View file

@ -6,11 +6,12 @@
# For Translators # For Translators
To translate Pleroma, add your language to [src/i18n/messages.js](https://git.pleroma.social/pleroma/pleroma-fe/blob/develop/src/i18n/messages.js). Pleroma will set your language by your browser locale, but you can temporarily force it in the code by changing the locale in main.js. To translate Pleroma-FE, add your language to [src/i18n/messages.js](https://git.pleroma.social/pleroma/pleroma-fe/blob/develop/src/i18n/messages.js). Pleroma-FE will set your language by your browser locale, but you can temporarily force it in the code by changing the locale in main.js.
# FOR ADMINS # FOR ADMINS
You don't need to build Pleroma yourself. Check out https://git.pleroma.social/pleroma/pleroma-fe/wikis/dual-boot-with-qvitter to see how to run Pleroma and Qvitter at the same time. You don't need to build Pleroma-FE yourself. Those using the Pleroma backend will be able to use it out of the box.
For the GNU social backend, check out https://git.pleroma.social/pleroma/pleroma-fe/wikis/dual-boot-with-qvitter to see how to run Pleroma-FE and Qvitter at the same time.
## Build Setup ## Build Setup

View file

@ -6,6 +6,8 @@ import InstanceSpecificPanel from './components/instance_specific_panel/instance
import FeaturesPanel from './components/features_panel/features_panel.vue' import FeaturesPanel from './components/features_panel/features_panel.vue'
import WhoToFollowPanel from './components/who_to_follow_panel/who_to_follow_panel.vue' import WhoToFollowPanel from './components/who_to_follow_panel/who_to_follow_panel.vue'
import ChatPanel from './components/chat_panel/chat_panel.vue' import ChatPanel from './components/chat_panel/chat_panel.vue'
import SideDrawer from './components/side_drawer/side_drawer.vue'
import { unseenNotificationsFromStore } from './services/notification_utils/notification_utils'
export default { export default {
name: 'app', name: 'app',
@ -17,7 +19,8 @@ export default {
InstanceSpecificPanel, InstanceSpecificPanel,
FeaturesPanel, FeaturesPanel,
WhoToFollowPanel, WhoToFollowPanel,
ChatPanel ChatPanel,
SideDrawer
}, },
data: () => ({ data: () => ({
mobileActivePanel: 'timeline', mobileActivePanel: 'timeline',
@ -70,12 +73,15 @@ export default {
sitename () { return this.$store.state.instance.name }, sitename () { return this.$store.state.instance.name },
chat () { return this.$store.state.chat.channel.state === 'joined' }, chat () { return this.$store.state.chat.channel.state === 'joined' },
suggestionsEnabled () { return this.$store.state.instance.suggestionsEnabled }, suggestionsEnabled () { return this.$store.state.instance.suggestionsEnabled },
showInstanceSpecificPanel () { return this.$store.state.instance.showInstanceSpecificPanel } showInstanceSpecificPanel () { return this.$store.state.instance.showInstanceSpecificPanel },
unseenNotifications () {
return unseenNotificationsFromStore(this.$store)
},
unseenNotificationsCount () {
return this.unseenNotifications.length
}
}, },
methods: { methods: {
activatePanel (panelName) {
this.mobileActivePanel = panelName
},
scrollToTop () { scrollToTop () {
window.scrollTo(0, 0) window.scrollTo(0, 0)
}, },
@ -85,6 +91,9 @@ export default {
}, },
onFinderToggled (hidden) { onFinderToggled (hidden) {
this.finderHidden = hidden this.finderHidden = hidden
},
toggleMobileSidebar () {
this.$refs.sideDrawer.toggleDrawer()
} }
} }
} }

View file

@ -473,6 +473,24 @@ nav {
} }
} }
.menu-button {
display: none;
position: relative;
}
.alert-dot {
border-radius: 100%;
height: 8px;
width: 8px;
position: absolute;
left: calc(50% - 4px);
top: calc(50% - 4px);
margin-left: 6px;
margin-top: -6px;
background-color: $fallback--cRed;
background-color: var(--badgeNotification, $fallback--cRed);
}
.fade-enter-active, .fade-leave-active { .fade-enter-active, .fade-leave-active {
transition: opacity .2s transition: opacity .2s
} }
@ -524,9 +542,6 @@ nav {
.back-button { .back-button {
display: none; display: none;
} }
.site-name {
padding-left: 20px;
}
} }
.sidebar-bounds { .sidebar-bounds {
@ -665,4 +680,9 @@ nav {
max-width: 4em; max-width: 4em;
} }
} }
.menu-button {
display: block;
margin-right: 0.8em;
}
} }

View file

@ -7,44 +7,42 @@
</div> </div>
<div class='inner-nav'> <div class='inner-nav'>
<div class='item'> <div class='item'>
<router-link class="back-button" @click.native="activatePanel('timeline')" :to="{ name: 'root' }" active-class="hidden"> <a href="#" class="menu-button" @click.stop.prevent="toggleMobileSidebar()">
<i class="icon-left-open" :title="$t('nav.back')"></i> <i class="button-icon icon-menu"></i>
</router-link> <div class="alert-dot" v-if="unseenNotificationsCount"></div>
</a>
<router-link class="site-name" :to="{ name: 'root' }" active-class="home">{{sitename}}</router-link> <router-link class="site-name" :to="{ name: 'root' }" active-class="home">{{sitename}}</router-link>
</div> </div>
<div class='item right'> <div class='item right'>
<user-finder class="button-icon nav-icon" @toggled="onFinderToggled"></user-finder> <user-finder class="button-icon nav-icon mobile-hidden" @toggled="onFinderToggled"></user-finder>
<router-link @click.native="activatePanel('timeline')" :to="{ name: 'settings'}"><i class="button-icon icon-cog nav-icon" :title="$t('nav.preferences')"></i></router-link> <router-link class="mobile-hidden" :to="{ name: 'settings'}"><i class="button-icon icon-cog nav-icon" :title="$t('nav.preferences')"></i></router-link>
<a href="#" v-if="currentUser" @click.prevent="logout"><i class="button-icon icon-logout nav-icon" :title="$t('login.logout')"></i></a> <a href="#" class="mobile-hidden" v-if="currentUser" @click.prevent="logout"><i class="button-icon icon-logout nav-icon" :title="$t('login.logout')"></i></a>
</div> </div>
</div> </div>
</nav> </nav>
<div class="container" id="content"> <div v-if="" class="container" id="content">
<div class="panel-switcher"> <side-drawer ref="sideDrawer" :logout="logout"></side-drawer>
<button @click="activatePanel('sidebar')">Sidebar</button> <div class="sidebar-flexer mobile-hidden">
<button @click="activatePanel('timeline')">Timeline</button>
</div>
<div class="sidebar-flexer" :class="{ 'mobile-hidden': mobileActivePanel != 'sidebar'}">
<div class="sidebar-bounds"> <div class="sidebar-bounds">
<div class="sidebar-scroller"> <div class="sidebar-scroller">
<div class="sidebar"> <div class="sidebar">
<user-panel :activatePanel="activatePanel"></user-panel> <user-panel></user-panel>
<nav-panel :activatePanel="activatePanel"></nav-panel> <nav-panel></nav-panel>
<instance-specific-panel v-if="showInstanceSpecificPanel"></instance-specific-panel> <instance-specific-panel v-if="showInstanceSpecificPanel"></instance-specific-panel>
<features-panel v-if="!currentUser"></features-panel> <features-panel v-if="!currentUser"></features-panel>
<who-to-follow-panel v-if="currentUser && suggestionsEnabled"></who-to-follow-panel> <who-to-follow-panel v-if="currentUser && suggestionsEnabled"></who-to-follow-panel>
<notifications :activatePanel="activatePanel" v-if="currentUser"></notifications> <notifications v-if="currentUser"></notifications>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="main" :class="{ 'mobile-hidden': mobileActivePanel != 'timeline' }"> <div class="main">
<transition name="fade"> <transition name="fade">
<router-view></router-view> <router-view></router-view>
</transition> </transition>
</div> </div>
</div> </div>
<chat-panel 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>
</div> </div>
</template> </template>

View file

@ -72,6 +72,7 @@ const afterStoreSetup = ({ store, i18n }) => {
var scopeCopy = (config.scopeCopy) var scopeCopy = (config.scopeCopy)
var subjectLineBehavior = (config.subjectLineBehavior) var subjectLineBehavior = (config.subjectLineBehavior)
var alwaysShowSubjectInput = (config.alwaysShowSubjectInput) var alwaysShowSubjectInput = (config.alwaysShowSubjectInput)
var noAttachmentLinks = (config.noAttachmentLinks)
store.dispatch('setInstanceOption', { name: 'theme', value: theme }) store.dispatch('setInstanceOption', { name: 'theme', value: theme })
store.dispatch('setInstanceOption', { name: 'background', value: background }) store.dispatch('setInstanceOption', { name: 'background', value: background })
@ -90,6 +91,8 @@ const afterStoreSetup = ({ store, i18n }) => {
store.dispatch('setInstanceOption', { name: 'scopeCopy', value: scopeCopy }) store.dispatch('setInstanceOption', { name: 'scopeCopy', value: scopeCopy })
store.dispatch('setInstanceOption', { name: 'subjectLineBehavior', value: subjectLineBehavior }) store.dispatch('setInstanceOption', { name: 'subjectLineBehavior', value: subjectLineBehavior })
store.dispatch('setInstanceOption', { name: 'alwaysShowSubjectInput', value: alwaysShowSubjectInput }) store.dispatch('setInstanceOption', { name: 'alwaysShowSubjectInput', value: alwaysShowSubjectInput })
store.dispatch('setInstanceOption', { name: 'noAttachmentLinks', value: noAttachmentLinks })
if (chatDisabled) { if (chatDisabled) {
store.dispatch('disableChat') store.dispatch('disableChat')
} }
@ -165,6 +168,8 @@ 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: 'restrictedNicknames', value: metadata.restrictedNicknames })
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 })

View file

@ -12,6 +12,10 @@ import UserSettings from 'components/user_settings/user_settings.vue'
import FollowRequests from 'components/follow_requests/follow_requests.vue' 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 UserPanel from 'components/user_panel/user_panel.vue'
import LoginForm from 'components/login_form/login_form.vue'
import ChatPanel from 'components/chat_panel/chat_panel.vue'
export default (store) => { export default (store) => {
return [ return [
@ -20,48 +24,28 @@ export default (store) => {
redirect: _to => { redirect: _to => {
return (store.state.users.currentUser return (store.state.users.currentUser
? store.state.instance.redirectRootLogin ? store.state.instance.redirectRootLogin
: store.state.instance.redirectRootNoLogin) || '/~/main/all' : store.state.instance.redirectRootNoLogin) || '/main/all'
} }
}, },
{ name: 'public-external-timeline', path: '/~/main/all', component: PublicAndExternalTimeline }, { name: 'public-external-timeline', path: '/main/all', component: PublicAndExternalTimeline },
{ name: 'public-timeline', path: '/~/main/public', component: PublicTimeline }, { name: 'public-timeline', path: '/main/public', component: PublicTimeline },
{ name: 'friends', path: '/~/main/friends', component: FriendsTimeline }, { name: 'friends', path: '/main/friends', component: FriendsTimeline },
// Beginning of temporary redirects { name: 'tag-timeline', path: '/tag/:tag', component: TagTimeline },
{ path: '/main/:route', { name: 'conversation', path: '/notice/:id', component: ConversationPage, meta: { dontScroll: true } },
redirect: to => { { name: 'external-user-profile', path: '/users/:id', component: UserProfile },
const { params } = to { name: 'mentions', path: '/users/:username/mentions', component: Mentions },
const route = params.route ? params.route : 'all' { name: 'dms', path: '/users/:username/dms', component: DMs },
{ name: 'settings', path: '/settings', component: Settings },
return { path: `/~/main/${route}` } { name: 'registration', path: '/registration', component: Registration },
} { name: 'registration', path: '/registration/:token', component: Registration },
}, { name: 'friend-requests', path: '/friend-requests', component: FollowRequests },
{ path: '/tag/:tag', { name: 'user-settings', path: '/user-settings', component: UserSettings },
redirect: to => { { name: 'notifications', path: '/:username/notifications', component: Notifications },
const { params } = to { name: 'new-status', path: '/:username/new-status', component: UserPanel },
{ name: 'login', path: '/login', component: LoginForm },
return { path: `/~/tag/${params.tag}` } { name: 'chat', path: '/chat', component: ChatPanel, props: () => ({ floating: false }) },
} { name: 'oauth-callback', path: '/oauth-callback', component: OAuthCallback, props: (route) => ({ code: route.query.code }) },
}, { name: 'user-search', path: '/user-search', component: UserSearch, props: (route) => ({ query: route.query.query }) },
{ path: '/notice/:id', { name: 'user-profile', path: '/(users/)?:name', component: UserProfile }
redirect: to => {
const { params } = to
return { path: `/~/notice/${params.id}` }
}
},
// End of temporary redirects
{ name: 'tag-timeline', path: '/~/tag/:tag', component: TagTimeline },
{ name: 'conversation', path: '/~/notice/:id', component: ConversationPage, meta: { dontScroll: true } },
{ name: 'user-profile', path: '/:name', component: UserProfile },
{ name: 'external-user-profile', path: '/~/users/:id', component: UserProfile },
{ name: 'mentions', path: '/:username/mentions', component: Mentions },
{ name: 'dms', path: '/:username/dms', component: DMs },
{ name: 'settings', path: '/~/settings', component: Settings },
{ name: 'registration', path: '/~/registration', component: Registration },
{ name: 'registration', path: '/~/registration/:token', component: Registration },
{ name: 'friend-requests', path: '/~/friend-requests', component: FollowRequests },
{ name: 'user-settings', path: '/~/user-settings', component: UserSettings },
{ name: 'oauth-callback', path: '/~/oauth-callback', component: OAuthCallback, props: (route) => ({ code: route.query.code }) },
{ name: 'user-search', path: '/~/user-search', component: UserSearch, props: (route) => ({ query: route.query.query }) }
] ]
} }

View file

@ -1,6 +1,7 @@
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'
const chatPanel = { const chatPanel = {
props: [ 'floating' ],
data () { data () {
return { return {
currentMessage: '', currentMessage: '',
@ -22,7 +23,7 @@ const chatPanel = {
this.collapsed = !this.collapsed this.collapsed = !this.collapsed
}, },
userProfileLink (user) { userProfileLink (user) {
return generateProfileLink(user.id, user.screen_name) return generateProfileLink(user.id, user.username, this.$store.state.instance.restrictedNicknames)
} }
} }
} }

View file

@ -1,10 +1,10 @@
<template> <template>
<div class="chat-panel" v-if="!this.collapsed"> <div class="chat-panel" v-if="!this.collapsed || !this.floating">
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading timeline-heading chat-heading" @click.stop.prevent="togglePanel"> <div class="panel-heading timeline-heading" :class="{ 'chat-heading': floating }" @click.stop.prevent="togglePanel">
<div class="title"> <div class="title">
{{$t('chat.title')}} {{$t('chat.title')}}
<i class="icon-cancel" style="float: right;"></i> <i class="icon-cancel" style="float: right;" v-if="floating"></i>
</div> </div>
</div> </div>
<div class="chat-window" v-chat-scroll> <div class="chat-window" v-chat-scroll>
@ -52,6 +52,7 @@
right: 0px; right: 0px;
bottom: 0px; bottom: 0px;
z-index: 1000; z-index: 1000;
max-width: 25em;
} }
.chat-heading { .chat-heading {
@ -63,10 +64,13 @@
} }
.chat-window { .chat-window {
width: 345px;
max-height: 40vh;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
max-height: 20em;
}
.chat-window-container {
height: 100%;
} }
.chat-message { .chat-message {

View file

@ -32,7 +32,7 @@ const LoginForm = {
.then((result) => { .then((result) => {
this.$store.commit('setToken', result.access_token) this.$store.commit('setToken', result.access_token)
this.$store.dispatch('loginUser', result.access_token) this.$store.dispatch('loginUser', result.access_token)
this.$router.push('/~/main/friends') this.$router.push({name: 'friends'})
}) })
}) })
} }

View file

@ -1,5 +1,4 @@
const NavPanel = { const NavPanel = {
props: [ 'activatePanel' ],
computed: { computed: {
currentUser () { currentUser () {
return this.$store.state.users.currentUser return this.$store.state.users.currentUser

View file

@ -3,32 +3,32 @@
<div class="panel panel-default"> <div class="panel panel-default">
<ul> <ul>
<li v-if='currentUser'> <li v-if='currentUser'>
<router-link @click.native="activatePanel('timeline')" :to="{ name: 'friends' }"> <router-link :to="{ name: 'friends' }">
{{ $t("nav.timeline") }} {{ $t("nav.timeline") }}
</router-link> </router-link>
</li> </li>
<li v-if='currentUser'> <li v-if='currentUser'>
<router-link @click.native="activatePanel('timeline')" :to="{ name: 'mentions', params: { username: currentUser.screen_name } }"> <router-link :to="{ name: 'mentions', params: { username: currentUser.screen_name } }">
{{ $t("nav.mentions") }} {{ $t("nav.mentions") }}
</router-link> </router-link>
</li> </li>
<li v-if='currentUser'> <li v-if='currentUser'>
<router-link @click.native="activatePanel('timeline')" :to="{ name: 'dms', params: { username: currentUser.screen_name } }"> <router-link :to="{ name: 'dms', params: { username: currentUser.screen_name } }">
{{ $t("nav.dms") }} {{ $t("nav.dms") }}
</router-link> </router-link>
</li> </li>
<li v-if='currentUser && currentUser.locked'> <li v-if='currentUser && currentUser.locked'>
<router-link @click.native="activatePanel('timeline')" :to="{ name: 'friend-requests' }"> <router-link :to="{ name: 'friend-requests' }">
{{ $t("nav.friend_requests") }} {{ $t("nav.friend_requests") }}
</router-link> </router-link>
</li> </li>
<li> <li>
<router-link @click.native="activatePanel('timeline')" :to="{ name: 'public-timeline' }"> <router-link :to="{ name: 'public-timeline' }">
{{ $t("nav.public_tl") }} {{ $t("nav.public_tl") }}
</router-link> </router-link>
</li> </li>
<li> <li>
<router-link @click.native="activatePanel('timeline')" :to="{ name: 'public-external-timeline' }"> <router-link :to="{ name: 'public-external-timeline' }">
{{ $t("nav.twkn") }} {{ $t("nav.twkn") }}
</router-link> </router-link>
</li> </li>

View file

@ -11,10 +11,7 @@ const Notification = {
betterShadow: this.$store.state.interface.browserSupport.cssFilter betterShadow: this.$store.state.interface.browserSupport.cssFilter
} }
}, },
props: [ props: [ 'notification' ],
'notification',
'activatePanel'
],
components: { components: {
Status, StillImage, UserCardContent Status, StillImage, UserCardContent
}, },
@ -23,7 +20,7 @@ const Notification = {
this.userExpanded = !this.userExpanded this.userExpanded = !this.userExpanded
}, },
userProfileLink (user) { userProfileLink (user) {
return generateProfileLink(user.id, user.screen_name) return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames)
} }
}, },
computed: { computed: {

View file

@ -1,5 +1,5 @@
<template> <template>
<status :activatePanel="activatePanel" v-if="notification.type === 'mention'" :compact="true" :statusoid="notification.status"></status> <status v-if="notification.type === 'mention'" :compact="true" :statusoid="notification.status"></status>
<div class="non-mention" :class="[userClass, { highlighted: userStyle }]" :style="[ userStyle ]"v-else> <div class="non-mention" :class="[userClass, { highlighted: userStyle }]" :style="[ userStyle ]"v-else>
<a class='avatar-container' :href="notification.action.user.statusnet_profile_url" @click.stop.prevent.capture="toggleUserExpanded"> <a class='avatar-container' :href="notification.action.user.statusnet_profile_url" @click.stop.prevent.capture="toggleUserExpanded">
<StillImage class='avatar-compact' :class="{'better-shadow': betterShadow}" :src="notification.action.user.profile_image_url_original"/> <StillImage class='avatar-compact' :class="{'better-shadow': betterShadow}" :src="notification.action.user.profile_image_url_original"/>
@ -25,15 +25,15 @@
<small>{{$t('notifications.followed_you')}}</small> <small>{{$t('notifications.followed_you')}}</small>
</span> </span>
</div> </div>
<small class="timeago"><router-link @click.native="activatePanel('timeline')" v-if="notification.status" :to="{ name: 'conversation', params: { id: notification.status.id } }"><timeago :since="notification.action.created_at" :auto-update="240"></timeago></router-link></small> <small class="timeago"><router-link v-if="notification.status" :to="{ name: 'conversation', params: { id: notification.status.id } }"><timeago :since="notification.action.created_at" :auto-update="240"></timeago></router-link></small>
</span> </span>
<div class="follow-text" v-if="notification.type === 'follow'"> <div class="follow-text" v-if="notification.type === 'follow'">
<router-link @click.native="activatePanel('timeline')" :to="userProfileLink(notification.action.user)"> <router-link :to="userProfileLink(notification.action.user)">
@{{notification.action.user.screen_name}} @{{notification.action.user.screen_name}}
</router-link> </router-link>
</div> </div>
<template v-else> <template v-else>
<status :activatePanel="activatePanel" class="faint" :compact="true" :statusoid="notification.status" :noHeading="true"></status> <status class="faint" :compact="true" :statusoid="notification.status" :noHeading="true"></status>
</template> </template>
</div> </div>
</div> </div>

View file

@ -1,10 +1,12 @@
import Notification from '../notification/notification.vue' import Notification from '../notification/notification.vue'
import notificationsFetcher from '../../services/notifications_fetcher/notifications_fetcher.service.js' import notificationsFetcher from '../../services/notifications_fetcher/notifications_fetcher.service.js'
import {
import { sortBy, filter } from 'lodash' notificationsFromStore,
visibleNotificationsFromStore,
unseenNotificationsFromStore
} from '../../services/notification_utils/notification_utils.js'
const Notifications = { const Notifications = {
props: [ 'activatePanel' ],
created () { created () {
const store = this.$store const store = this.$store
const credentials = store.state.users.currentUser.credentials const credentials = store.state.users.currentUser.credentials
@ -12,28 +14,17 @@ const Notifications = {
notificationsFetcher.startFetching({ store, credentials }) notificationsFetcher.startFetching({ store, credentials })
}, },
computed: { computed: {
visibleTypes () {
return [
this.$store.state.config.notificationVisibility.likes && 'like',
this.$store.state.config.notificationVisibility.mentions && 'mention',
this.$store.state.config.notificationVisibility.repeats && 'repeat',
this.$store.state.config.notificationVisibility.follows && 'follow'
].filter(_ => _)
},
notifications () { notifications () {
return this.$store.state.statuses.notifications.data return notificationsFromStore(this.$store)
}, },
error () { error () {
return this.$store.state.statuses.notifications.error return this.$store.state.statuses.notifications.error
}, },
unseenNotifications () { unseenNotifications () {
return filter(this.visibleNotifications, ({seen}) => !seen) return unseenNotificationsFromStore(this.$store)
}, },
visibleNotifications () { visibleNotifications () {
// Don't know why, but sortBy([seen, -action.id]) doesn't work. return visibleNotificationsFromStore(this.$store)
let sortedNotifications = sortBy(this.notifications, ({action}) => -action.id)
sortedNotifications = sortBy(sortedNotifications, 'seen')
return sortedNotifications.filter((notification) => this.visibleTypes.includes(notification.type))
}, },
unseenCount () { unseenCount () {
return this.unseenNotifications.length return this.unseenNotifications.length

View file

@ -14,7 +14,7 @@
<div class="panel-body"> <div class="panel-body">
<div v-for="notification in visibleNotifications" :key="notification.action.id" class="notification" :class='{"unseen": !notification.seen}'> <div v-for="notification in visibleNotifications" :key="notification.action.id" class="notification" :class='{"unseen": !notification.seen}'>
<div class="notification-overlay"></div> <div class="notification-overlay"></div>
<notification :activatePanel="activatePanel" :notification="notification"></notification> <notification :notification="notification"></notification>
</div> </div>
</div> </div>
<div class="panel-footer"> <div class="panel-footer">

View file

@ -11,7 +11,7 @@ const oac = {
}).then((result) => { }).then((result) => {
this.$store.commit('setToken', result.access_token) this.$store.commit('setToken', result.access_token)
this.$store.dispatch('loginUser', result.access_token) this.$store.dispatch('loginUser', result.access_token)
this.$router.push('/~/main/friends') this.$router.push({name: 'friends'})
}) })
} }
} }

View file

@ -249,6 +249,7 @@ const PostStatusForm = {
} }
this.$emit('posted') this.$emit('posted')
let el = this.$el.querySelector('textarea') let el = this.$el.querySelector('textarea')
el.style.height = 'auto'
el.style.height = undefined el.style.height = undefined
this.error = null this.error = null
} else { } else {

View file

@ -28,7 +28,7 @@ const registration = {
}, },
created () { created () {
if ((!this.registrationOpen && !this.token) || this.signedIn) { if ((!this.registrationOpen && !this.token) || this.signedIn) {
this.$router.push('/~/main/all') this.$router.push({name: 'root'})
} }
this.setCaptcha() this.setCaptcha()
@ -48,15 +48,17 @@ const registration = {
async submit () { async submit () {
this.user.nickname = this.user.username this.user.nickname = this.user.username
this.user.token = this.token this.user.token = this.token
this.user.captcha_solution = this.captcha.solution this.user.captcha_solution = this.captcha.solution
this.user.captcha_token = this.captcha.token this.user.captcha_token = this.captcha.token
this.user.captcha_answer_data = this.captcha.answer_data
this.$v.$touch() this.$v.$touch()
if (!this.$v.$invalid) { if (!this.$v.$invalid) {
try { try {
await this.signUp(this.user) await this.signUp(this.user)
this.$router.push('/~/main/friends') this.$router.push({name: 'friends'})
} catch (error) { } catch (error) {
console.warn('Registration failed: ' + error) console.warn('Registration failed: ' + error)
} }

View file

@ -76,15 +76,16 @@
</div> </div>
<div class="form-group" id="captcha-group" v-if="captcha.type != 'none'"> <div class="form-group" id="captcha-group" v-if="captcha.type != 'none'">
<label class='form--label' for='captcha-label'>{{$t('captcha')}}</label>
<template v-if="captcha.type == 'kocaptcha'"> <template v-if="captcha.type == 'kocaptcha'">
<img v-bind:src="captcha.url" v-on:click="setCaptcha"> <img v-bind:src="captcha.url" v-on:click="setCaptcha">
<sub>Click the image to get a new captcha</sub> <sub>{{$t('registration.new_captcha')}}</sub>
<label class='form--label' for='captcha-label'>CAPTCHA</label>
<input :disabled="isPending" <input :disabled="isPending"
v-model='captcha.solution' v-model='captcha.solution'
class='form-control' id='captcha-answer' type='text'> class='form-control' id='captcha-answer' type='text' autocomplete="off">
</template> </template>
</div> </div>

View file

@ -0,0 +1,48 @@
import UserCardContent from '../user_card_content/user_card_content.vue'
import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils'
// TODO: separate touch gesture stuff into their own utils if more components want them
const deltaCoord = (oldCoord, newCoord) => [newCoord[0] - oldCoord[0], newCoord[1] - oldCoord[1]]
const touchEventCoord = e => ([e.touches[0].screenX, e.touches[0].screenY])
const SideDrawer = {
props: [ 'logout' ],
data: () => ({
closed: true,
touchCoord: [0, 0]
}),
components: { UserCardContent },
computed: {
currentUser () {
return this.$store.state.users.currentUser
},
chat () { return this.$store.state.chat.channel.state === 'joined' },
unseenNotifications () {
return unseenNotificationsFromStore(this.$store)
},
unseenNotificationsCount () {
return this.unseenNotifications.length
}
},
methods: {
toggleDrawer () {
this.closed = !this.closed
},
doLogout () {
this.logout()
this.toggleDrawer()
},
touchStart (e) {
this.touchCoord = touchEventCoord(e)
},
touchMove (e) {
const delta = deltaCoord(this.touchCoord, touchEventCoord(e))
if (delta[0] < -30 && Math.abs(delta[1]) < Math.abs(delta[0]) && !this.closed) {
this.toggleDrawer()
}
}
}
}
export default SideDrawer

View file

@ -0,0 +1,189 @@
<template>
<div class="side-drawer-container"
:class="{ 'side-drawer-container-closed': closed, 'side-drawer-container-open': !closed }"
>
<div class="side-drawer"
:class="{'side-drawer-closed': closed}"
@touchstart="touchStart"
@touchmove="touchMove"
>
<div class="side-drawer-heading" @click="toggleDrawer">
<user-card-content :user="currentUser" :switcher="false" :hideBio="true" v-if="currentUser">
</user-card-content>
</div>
<ul>
<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' }">
{{ $t("login.login") }}
</router-link>
</li>
<li v-if="currentUser" @click="toggleDrawer">
<router-link :to="{ name: 'notifications', params: { username: currentUser.screen_name } }">
{{ $t("notifications.notifications") }} {{ unseenNotificationsCount > 0 ? `(${unseenNotificationsCount})` : '' }}
</router-link>
</li>
<li v-if="currentUser" @click="toggleDrawer">
<router-link :to="{ name: 'dms', params: { username: currentUser.screen_name } }">
{{ $t("nav.dms") }}
</router-link>
</li>
</ul>
<ul>
<li v-if="currentUser" @click="toggleDrawer">
<router-link :to="{ name: 'friends' }">
{{ $t("nav.timeline") }}
</router-link>
</li>
<li v-if="currentUser && currentUser.locked" @click="toggleDrawer">
<router-link to='/friend-requests'>
{{ $t("nav.friend_requests") }}
</router-link>
</li>
<li @click="toggleDrawer">
<router-link to='/main/public'>
{{ $t("nav.public_tl") }}
</router-link>
</li>
<li @click="toggleDrawer">
<router-link to='/main/all'>
{{ $t("nav.twkn") }}
</router-link>
</li>
<li v-if="currentUser && chat" @click="toggleDrawer">
<router-link :to="{ name: 'chat' }">
{{ $t("nav.chat") }}
</router-link>
</li>
</ul>
<ul>
<li @click="toggleDrawer">
<router-link :to="{ name: 'user-search'}">
{{ $t("nav.user_search") }}
</router-link>
</li>
<li @click="toggleDrawer">
<router-link :to="{ name: 'settings'}">
{{ $t("settings.settings") }}
</router-link>
</li>
<li v-if="currentUser" @click="toggleDrawer">
<a @click="doLogout" href="#">
{{ $t("login.logout") }}
</a>
</li>
</ul>
</div>
<div class="side-drawer-click-outside"
@click.stop.prevent="toggleDrawer"
:class="{'side-drawer-click-outside-closed': closed}"
></div>
</div>
</template>
<script src="./side_drawer.js" ></script>
<style lang="scss">
@import '../../_variables.scss';
.side-drawer-container {
position: fixed;
z-index: 1000;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: stretch;
}
.side-drawer-container-open {
transition-delay: 0.0s;
transition-property: left;
}
.side-drawer-container-closed {
left: -100%;
transition-delay: 0.5s;
transition-property: left;
}
.side-drawer-click-outside {
flex: 1 1 100%;
}
.side-drawer {
overflow-x: hidden;
transition: 0.5s;
transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
margin: 0 0 0 -100px;
padding: 0 0 1em 100px;
width: 80%;
max-width: 20em;
flex: 0 0 80%;
box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.6);
box-shadow: var(--panelShadow);
background-color: $fallback--bg;
background-color: var(--bg, $fallback--bg);
}
.side-drawer-click-outside-closed {
flex: 0 0 0;
}
.side-drawer-closed {
margin: 0 0 0 calc(-100% - 100px);
}
.side-drawer-heading {
background: transparent;
flex-direction: column;
align-items: stretch;
display: flex;
min-height: 7em;
padding: 0;
margin: 0;
.profile-panel-background {
border-radius: 0;
.panel-heading {
background: transparent;
flex-direction: column;
align-items: stretch;
}
}
}
.side-drawer ul {
list-style: none;
margin: 0;
padding: 0;
border-bottom: 1px solid;
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
margin: 0.2em 0;
}
.side-drawer ul:last-child {
border: 0;
}
.side-drawer li {
padding: 0;
a {
display: block;
padding: 0.5em 0.85em;
&:hover {
background-color: $fallback--lightBg;
background-color: var(--lightBg, $fallback--lightBg);
}
}
}
</style>

View file

@ -21,8 +21,7 @@ const Status = {
'replies', 'replies',
'noReplyLinks', 'noReplyLinks',
'noHeading', 'noHeading',
'inlineExpanded', 'inlineExpanded'
'activatePanel'
], ],
data () { data () {
return { return {
@ -291,7 +290,7 @@ const Status = {
this.showPreview = false this.showPreview = false
}, },
userProfileLink (id, name) { userProfileLink (id, name) {
return generateProfileLink(id, name) return generateProfileLink(id, name, this.$store.state.instance.restrictedNicknames)
} }
}, },
watch: { watch: {

View file

@ -3,7 +3,7 @@
<template v-if="muted && !noReplyLinks"> <template v-if="muted && !noReplyLinks">
<div class="media status container muted"> <div class="media status container muted">
<small> <small>
<router-link @click.native="activatePanel('timeline')" :to="userProfileLink(status.user.id, status.user.screen_name)"> <router-link :to="userProfileLink(status.user.id, status.user.screen_name)">
{{status.user.screen_name}} {{status.user.screen_name}}
</router-link> </router-link>
</small> </small>
@ -38,12 +38,12 @@
<h4 class="user-name" v-if="status.user.name_html" v-html="status.user.name_html"></h4> <h4 class="user-name" v-if="status.user.name_html" v-html="status.user.name_html"></h4>
<h4 class="user-name" v-else>{{status.user.name}}</h4> <h4 class="user-name" v-else>{{status.user.name}}</h4>
<span class="links"> <span class="links">
<router-link @click.native="activatePanel('timeline')" :to="userProfileLink(status.user.id, status.user.screen_name)"> <router-link :to="userProfileLink(status.user.id, status.user.screen_name)">
{{status.user.screen_name}} {{status.user.screen_name}}
</router-link> </router-link>
<span v-if="status.in_reply_to_screen_name" class="faint reply-info"> <span v-if="status.in_reply_to_screen_name" class="faint reply-info">
<i class="icon-right-open"></i> <i class="icon-right-open"></i>
<router-link @click.native="activatePanel('timeline')" :to="userProfileLink(status.in_reply_to_user_id, status.in_reply_to_screen_name)"> <router-link :to="userProfileLink(status.in_reply_to_user_id, status.in_reply_to_screen_name)">
{{status.in_reply_to_screen_name}} {{status.in_reply_to_screen_name}}
</router-link> </router-link>
</span> </span>
@ -60,7 +60,7 @@
</h4> </h4>
</div> </div>
<div class="media-heading-right"> <div class="media-heading-right">
<router-link class="timeago" @click.native="activatePanel('timeline')" :to="{ name: 'conversation', params: { id: status.id } }"> <router-link class="timeago" :to="{ name: 'conversation', params: { id: status.id } }">
<timeago :since="status.created_at" :auto-update="60"></timeago> <timeago :since="status.created_at" :auto-update="60"></timeago>
</router-link> </router-link>
<div class="button-icon visibility-icon" v-if="status.visibility"> <div class="button-icon visibility-icon" v-if="status.visibility">
@ -79,7 +79,7 @@
</div> </div>
<div v-if="showPreview" class="status-preview-container"> <div v-if="showPreview" class="status-preview-container">
<status :activatePanel="activatePanel" class="status-preview" v-if="preview" :noReplyLinks="true" :statusoid="preview" :compact=true></status> <status class="status-preview" v-if="preview" :noReplyLinks="true" :statusoid="preview" :compact=true></status>
<div class="status-preview status-preview-loading" v-else> <div class="status-preview status-preview-loading" v-else>
<i class="icon-spin4 animate-spin"></i> <i class="icon-spin4 animate-spin"></i>
</div> </div>

View file

@ -33,7 +33,7 @@ const UserCard = {
this.$store.dispatch('removeFollowRequest', this.user) this.$store.dispatch('removeFollowRequest', this.user)
}, },
userProfileLink (user) { userProfileLink (user) {
return generateProfileLink(user.id, user.screen_name) return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames)
} }
} }
} }

View file

@ -3,7 +3,7 @@ import { hex2rgb } from '../../services/color_convert/color_convert.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
export default { export default {
props: [ 'user', 'switcher', 'selected', 'hideBio', 'activatePanel' ], props: [ 'user', 'switcher', 'selected', 'hideBio' ],
data () { data () {
return { return {
followRequestInProgress: false, followRequestInProgress: false,
@ -180,7 +180,7 @@ export default {
} }
}, },
userProfileLink (user) { userProfileLink (user) {
return generateProfileLink(user.id, user.screen_name) return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames)
} }
} }
} }

View file

@ -2,22 +2,25 @@
<div id="heading" class="profile-panel-background" :style="headingStyle"> <div id="heading" class="profile-panel-background" :style="headingStyle">
<div class="panel-heading text-center"> <div class="panel-heading text-center">
<div class='user-info'> <div class='user-info'>
<router-link @click.native="activatePanel && activatePanel('timeline')" :to="{ name: 'user-settings' }" style="float: right; margin-top:16px;" v-if="!isOtherUser">
<i class="button-icon icon-cog usersettings" :title="$t('tool_tip.user_settings')"></i>
</router-link>
<a :href="user.statusnet_profile_url" target="_blank" class="floater" v-if="isOtherUser">
<i class="icon-link-ext usersettings"></i>
</a>
<div class='container'> <div class='container'>
<router-link @click.native="activatePanel && activatePanel('timeline')" :to="userProfileLink(user)"> <router-link :to="userProfileLink(user)">
<StillImage class="avatar" :class='{ "better-shadow": betterShadow }' :src="user.profile_image_url_original"/> <StillImage class="avatar" :class='{ "better-shadow": betterShadow }' :src="user.profile_image_url_original"/>
</router-link> </router-link>
<div class="name-and-screen-name"> <div class="name-and-screen-name">
<div class="top-line">
<div :title="user.name" class='user-name' v-if="user.name_html" v-html="user.name_html"></div> <div :title="user.name" class='user-name' v-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 @click.native="activatePanel && activatePanel('timeline')" class='user-screen-name' :to="userProfileLink(user)"> <router-link :to="{ name: 'user-settings' }" v-if="!isOtherUser">
<span>@{{user.screen_name}}</span><span v-if="user.locked"><i class="icon icon-lock"></i></span> <i class="button-icon icon-cog usersettings" :title="$t('tool_tip.user_settings')"></i>
<span v-if="!hideUserStatsLocal" class="dailyAvg">{{dailyAvg}} {{ $t('user_card.per_day') }}</span> </router-link>
<a :href="user.statusnet_profile_url" target="_blank" v-if="isOtherUser">
<i class="icon-link-ext usersettings"></i>
</a>
</div>
<router-link class='user-screen-name' :to="userProfileLink(user)">
<span class="handle">@{{user.screen_name}}</span><span v-if="user.locked"><i class="icon icon-lock"></i></span>
<span v-if="!hideUserStatsLocal && !hideBio" class="dailyAvg">{{dailyAvg}} {{ $t('user_card.per_day') }}</span>
</router-link> </router-link>
</div> </div>
</div> </div>
@ -25,7 +28,7 @@
<div v-if="user.follows_you && loggedIn && isOtherUser" class="following"> <div v-if="user.follows_you && loggedIn && isOtherUser" class="following">
{{ $t('user_card.follows_you') }} {{ $t('user_card.follows_you') }}
</div> </div>
<div class="floater" v-if="isOtherUser && (loggedIn || !switcher)"> <div class="highlighter" v-if="isOtherUser && (loggedIn || !switcher)">
<!-- id's need to be unique, otherwise vue confuses which user-card checkbox belongs to --> <!-- id's need to be unique, otherwise vue confuses which user-card checkbox belongs to -->
<input class="userHighlightText" type="text" :id="'userHighlightColorTx'+user.id" v-if="userHighlightType !== 'disabled'" v-model="userHighlightColor"/> <input class="userHighlightText" type="text" :id="'userHighlightColorTx'+user.id" v-if="userHighlightType !== 'disabled'" v-model="userHighlightColor"/>
<input class="userHighlightCl" type="color" :id="'userHighlightColor'+user.id" v-if="userHighlightType !== 'disabled'" v-model="userHighlightColor"/> <input class="userHighlightCl" type="color" :id="'userHighlightColor'+user.id" v-if="userHighlightType !== 'disabled'" v-model="userHighlightColor"/>
@ -139,7 +142,7 @@
border-bottom-right-radius: 0; border-bottom-right-radius: 0;
.panel-heading { .panel-heading {
padding: 0.6em 0em; padding: .6em 0;
text-align: center; text-align: center;
box-shadow: none; box-shadow: none;
} }
@ -158,10 +161,10 @@
.user-info { .user-info {
color: $fallback--lightText; color: $fallback--lightText;
color: var(--lightText, $fallback--lightText); color: var(--lightText, $fallback--lightText);
padding: 0 16px; padding: 0 26px;
.container { .container {
padding: 16px 10px 6px 10px; padding: 16px 0 6px;
display: flex; display: flex;
max-height: 56px; max-height: 56px;
@ -218,11 +221,15 @@
vertical-align: middle; vertical-align: middle;
object-fit: contain object-fit: contain
} }
.top-line {
display: flex;
}
} }
.user-name{ .user-name{
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
flex: 1 0 auto;
} }
.user-screen-name { .user-screen-name {
@ -232,27 +239,73 @@
font-weight: light; font-weight: light;
font-size: 15px; font-size: 15px;
padding-right: 0.1em; padding-right: 0.1em;
width: 100%;
display: flex;
.dailyAvg {
min-width: 1px;
flex: 0 0 auto;
}
.handle {
min-width: 1px;
flex: 0 1 auto;
text-overflow: ellipsis;
overflow: hidden;
}
} }
.user-meta { .user-meta {
margin-bottom: .4em; margin-bottom: .15em;
display: flex;
align-items: baseline;
font-size: 14px;
line-height: 22px;
flex-wrap: wrap;
.following { .following {
font-size: 14px; flex: 1 0 auto;
flex: 0 0 100%;
margin: 0; margin: 0;
padding-left: 16px; margin-bottom: .25em;
text-align: left; text-align: left;
float: left;
}
.floater {
margin: 0;
} }
&::after { .highlighter {
display: block; flex: 0 1 auto;
content: ''; display: flex;
clear: both; flex-wrap: wrap;
margin-right: -.5em;
align-self: start;
.userHighlightCl {
padding: 2px 10px;
flex: 1 0 auto;
}
.userHighlightSel,
.userHighlightSel.select {
padding-top: 0;
padding-bottom: 0;
flex: 1 0 auto;
}
.userHighlightSel.select i {
line-height: 22px;
}
.userHighlightText {
width: 70px;
flex: 1 0 auto;
}
.userHighlightCl,
.userHighlightText,
.userHighlightSel,
.userHighlightSel.select {
height: 22px;
vertical-align: top;
margin-right: .5em;
margin-bottom: .25em;
}
} }
} }
.user-interactions { .user-interactions {
@ -260,8 +313,13 @@
flex-flow: row wrap; flex-flow: row wrap;
justify-content: space-between; justify-content: space-between;
margin-right: -.75em;
div { div {
flex: 1; flex: 1 0 0;
margin-right: .75em;
margin-bottom: .6em;
white-space: nowrap;
} }
.mute { .mute {
@ -280,8 +338,9 @@
} }
button { button {
width: 92%; width: 100%;
height: 100%; height: 100%;
margin: 0;
} }
.remote-button { .remote-button {
@ -304,10 +363,11 @@
justify-content: space-between; justify-content: space-between;
color: $fallback--lightText; color: $fallback--lightText;
color: var(--lightText, $fallback--lightText); color: var(--lightText, $fallback--lightText);
flex-wrap: wrap;
} }
.user-count { .user-count {
flex: 1; flex: 1 0 auto;
padding: .5em 0 .5em 0; padding: .5em 0 .5em 0;
margin: 0 .5em; margin: 0 .5em;
@ -327,32 +387,5 @@
color: #CCC; color: #CCC;
} }
.floater { .floater {
float: right;
margin-top: 16px;
.userHighlightCl {
padding: 2px 10px;
}
.userHighlightSel,
.userHighlightSel.select {
padding-top: 0;
padding-bottom: 0;
}
.userHighlightSel.select i {
line-height: 22px;
}
.userHighlightText {
width: 70px;
}
.userHighlightCl,
.userHighlightText,
.userHighlightSel,
.userHighlightSel.select {
height: 22px;
vertical-align: top;
margin-right: 0
}
} }
</style> </style>

View file

@ -1,4 +1,5 @@
<template> <template>
<div>
<div class="user-finder-container"> <div class="user-finder-container">
<i class="icon-spin4 user-finder-icon animate-spin-slow" v-if="loading" /> <i class="icon-spin4 user-finder-icon animate-spin-slow" v-if="loading" />
<a href="#" v-if="hidden" :title="$t('finder.find_user')"><i class="icon-user-plus user-finder-icon" @click.prevent.stop="toggleHidden" /></a> <a href="#" v-if="hidden" :title="$t('finder.find_user')"><i class="icon-user-plus user-finder-icon" @click.prevent.stop="toggleHidden" /></a>
@ -10,6 +11,7 @@
<i class="button-icon icon-cancel user-finder-icon" @click.prevent.stop="toggleHidden"/> <i class="button-icon icon-cancel user-finder-icon" @click.prevent.stop="toggleHidden"/>
</template> </template>
</div> </div>
</div>
</template> </template>
<script src="./user_finder.js"></script> <script src="./user_finder.js"></script>

View file

@ -3,7 +3,6 @@ import PostStatusForm from '../post_status_form/post_status_form.vue'
import UserCardContent from '../user_card_content/user_card_content.vue' import UserCardContent from '../user_card_content/user_card_content.vue'
const UserPanel = { const UserPanel = {
props: [ 'activatePanel' ],
computed: { computed: {
user () { return this.$store.state.users.currentUser } user () { return this.$store.state.users.currentUser }
}, },

View file

@ -1,7 +1,7 @@
<template> <template>
<div class="user-panel"> <div class="user-panel">
<div v-if='user' class="panel panel-default" style="overflow: visible;"> <div v-if='user' class="panel panel-default" style="overflow: visible;">
<user-card-content :activatePanel="activatePanel" :user="user" :switcher="false" :hideBio="true"></user-card-content> <user-card-content :user="user" :switcher="false" :hideBio="true"></user-card-content>
<div class="panel-footer"> <div class="panel-footer">
<post-status-form v-if='user'></post-status-form> <post-status-form v-if='user'></post-status-form>
</div> </div>

View file

@ -6,7 +6,7 @@ const UserProfile = {
created () { created () {
this.$store.commit('clearTimeline', { timeline: 'user' }) this.$store.commit('clearTimeline', { timeline: 'user' })
this.$store.dispatch('startFetching', ['user', this.fetchBy]) this.$store.dispatch('startFetching', ['user', this.fetchBy])
if (!this.user) { if (!this.user.id) {
this.$store.dispatch('fetchUser', this.fetchBy) this.$store.dispatch('fetchUser', this.fetchBy)
} }
}, },
@ -29,14 +29,20 @@ const UserProfile = {
followers () { followers () {
return this.user.followers return this.user.followers
}, },
userInStore () {
if (this.isExternal) {
return this.$store.getters.userById(this.userId)
}
return this.$store.getters.userByName(this.userName)
},
user () { user () {
if (this.timeline.statuses[0]) { if (this.timeline.statuses[0]) {
return this.timeline.statuses[0].user return this.timeline.statuses[0].user
} else {
return Object.values(this.$store.state.users.usersObject).filter(user => {
return (this.isExternal ? user.id === this.userId : user.screen_name === this.userName)
})[0] || {}
} }
if (this.userInStore) {
return this.userInStore
}
return {}
}, },
fetchBy () { fetchBy () {
return this.isExternal ? this.userId : this.userName return this.isExternal ? this.userId : this.userName

View file

@ -43,7 +43,7 @@
flex: 2; flex: 2;
flex-basis: 500px; flex-basis: 500px;
.panel-heading { .profile-panel-background .panel-heading {
background: transparent; background: transparent;
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;

View file

@ -9,6 +9,7 @@ const userSearch = {
], ],
data () { data () {
return { return {
username: '',
users: [] users: []
} }
}, },
@ -21,7 +22,14 @@ const userSearch = {
} }
}, },
methods: { methods: {
newQuery (query) {
this.$router.push({ name: 'user-search', query: { query } })
},
search (query) { search (query) {
if (!query) {
this.users = []
return
}
userSearchApi.search({query, store: this.$store}) userSearchApi.search({query, store: this.$store})
.then((res) => { .then((res) => {
this.users = res this.users = res

View file

@ -3,6 +3,12 @@
<div class="panel-heading"> <div class="panel-heading">
{{$t('nav.user_search')}} {{$t('nav.user_search')}}
</div> </div>
<div class="user-search-input-container">
<input class="user-finder-input" @keyup.enter="newQuery(username)" v-model="username" :placeholder="$t('finder.find_user')"/>
<button class="btn search-button" @click="newQuery(username)">
<i class="icon-search"/>
</button>
</div>
<div class="panel-body"> <div class="panel-body">
<user-card v-for="user in users" :key="user.id" :user="user" :showFollows="true"></user-card> <user-card v-for="user in users" :key="user.id" :user="user" :showFollows="true"></user-card>
</div> </div>
@ -10,3 +16,15 @@
</template> </template>
<script src="./user_search.js"></script> <script src="./user_search.js"></script>
<style lang="scss">
.user-search-input-container {
margin: 0.5em;
display: flex;
justify-content: center;
.search-button {
margin-left: 0.5em;
}
}
</style>

View file

@ -257,7 +257,7 @@ const UserSettings = {
.then((res) => { .then((res) => {
if (res.status === 'success') { if (res.status === 'success') {
this.$store.dispatch('logout') this.$store.dispatch('logout')
this.$router.push('/~/main/all') this.$router.push({name: 'root'})
} else { } else {
this.deleteAccountError = res.error this.deleteAccountError = res.error
} }

View file

@ -1,63 +1,34 @@
import apiService from '../../services/api/api.service.js' import apiService from '../../services/api/api.service.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import _ from 'lodash'
function showWhoToFollow (panel, reply) { function showWhoToFollow (panel, reply) {
var users = reply _.shuffle(reply)
var cn
var index panel.usersToFollow.forEach((toFollow, index) => {
var step = 7 let user = reply[index]
cn = Math.floor(Math.random() * step) let img = user.avatar || '/images/avi.png'
for (index = 0; index < 3; index++) { let name = user.acct
var user
user = users[cn] toFollow.img = img
var img toFollow.name = name
if (user.avatar) {
img = user.avatar
} else {
img = '/images/avi.png'
}
var name = user.acct
if (index === 0) {
panel.img1 = img
panel.name1 = name
panel.$store.state.api.backendInteractor.externalProfile(name) panel.$store.state.api.backendInteractor.externalProfile(name)
.then((externalUser) => { .then((externalUser) => {
if (!externalUser.error) { if (!externalUser.error) {
panel.$store.commit('addNewUsers', [externalUser]) panel.$store.commit('addNewUsers', [externalUser])
panel.id1 = externalUser.id toFollow.id = externalUser.id
} }
}) })
} else if (index === 1) {
panel.img2 = img
panel.name2 = name
panel.$store.state.api.backendInteractor.externalProfile(name)
.then((externalUser) => {
if (!externalUser.error) {
panel.$store.commit('addNewUsers', [externalUser])
panel.id2 = externalUser.id
}
}) })
} else if (index === 2) {
panel.img3 = img
panel.name3 = name
panel.$store.state.api.backendInteractor.externalProfile(name)
.then((externalUser) => {
if (!externalUser.error) {
panel.$store.commit('addNewUsers', [externalUser])
panel.id3 = externalUser.id
}
})
}
cn = (cn + step) % users.length
}
} }
function getWhoToFollow (panel) { function getWhoToFollow (panel) {
var credentials = panel.$store.state.users.currentUser.credentials var credentials = panel.$store.state.users.currentUser.credentials
if (credentials) { if (credentials) {
panel.name1 = 'Loading...' panel.usersToFollow.forEach(toFollow => {
panel.name2 = 'Loading...' toFollow.name = 'Loading...'
panel.name3 = 'Loading...' })
apiService.suggestions({credentials: credentials}) apiService.suggestions({credentials: credentials})
.then((reply) => { .then((reply) => {
showWhoToFollow(panel, reply) showWhoToFollow(panel, reply)
@ -67,27 +38,24 @@ function getWhoToFollow (panel) {
const WhoToFollowPanel = { const WhoToFollowPanel = {
data: () => ({ data: () => ({
img1: '/images/avi.png', usersToFollow: new Array(3).fill().map(x => (
name1: '', {
id1: 0, img: '/images/avi.png',
img2: '/images/avi.png', name: '',
name2: '', id: 0
id2: 0, }
img3: '/images/avi.png', ))
name3: '',
id3: 0
}), }),
computed: { computed: {
user: function () { user: function () {
return this.$store.state.users.currentUser.screen_name return this.$store.state.users.currentUser.screen_name
}, },
moreUrl: function () { moreUrl: function () {
var host = window.location.hostname const host = window.location.hostname
var user = this.user const user = this.user
var suggestionsWeb = this.$store.state.instance.suggestionsWeb const suggestionsWeb = this.$store.state.instance.suggestionsWeb
var url const url = suggestionsWeb.replace(/{{host}}/g, encodeURIComponent(host))
url = suggestionsWeb.replace(/{{host}}/g, encodeURIComponent(host)) .replace(/{{user}}/g, encodeURIComponent(user))
url = url.replace(/{{user}}/g, encodeURIComponent(user))
return url return url
}, },
suggestionsEnabled () { suggestionsEnabled () {
@ -96,7 +64,7 @@ const WhoToFollowPanel = {
}, },
methods: { methods: {
userProfileLink (id, name) { userProfileLink (id, name) {
return generateProfileLink(id, name) return generateProfileLink(id, name, this.$store.state.instance.restrictedNicknames)
} }
}, },
watch: { watch: {

View file

@ -7,12 +7,13 @@
</div> </div>
</div> </div>
<div class="panel-body who-to-follow"> <div class="panel-body who-to-follow">
<p> <span v-for="user in usersToFollow">
<img v-bind:src="img1"/> <router-link :to="userProfileLink(id1, name1)">{{ name1 }}</router-link><br> <img v-bind:src="user.img" />
<img v-bind:src="img2"/> <router-link :to="userProfileLink(id2, name2)">{{ name2 }}</router-link><br> <router-link v-bind:to="userProfileLink(user.id, user.name)">
<img v-bind:src="img3"/> <router-link :to="userProfileLink(id3, name3)">{{ name3 }}</router-link><br> {{user.name}}
</router-link><br />
</span>
<img v-bind:src="$store.state.instance.logo"> <a v-bind:href="moreUrl" target="_blank">{{$t('who_to_follow.more')}}</a> <img v-bind:src="$store.state.instance.logo"> <a v-bind:href="moreUrl" target="_blank">{{$t('who_to_follow.more')}}</a>
</p>
</div> </div>
</div> </div>
</div> </div>
@ -28,7 +29,9 @@
width: 32px; width: 32px;
height: 32px; height: 32px;
} }
.who-to-follow p { .who-to-follow {
padding: 0.5em 1em 0.5em 1em;
margin: 0px;
line-height: 40px; line-height: 40px;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;

View file

@ -6,7 +6,7 @@
"chat": "Chat", "chat": "Chat",
"gopher": "Gopher", "gopher": "Gopher",
"media_proxy": "Media Proxy", "media_proxy": "Media Proxy",
"scope_options": "Scope options", "scope_options": "Reichweitenoptionen",
"text_limit": "Textlimit", "text_limit": "Textlimit",
"title": "Features", "title": "Features",
"who_to_follow": "Who to follow" "who_to_follow": "Who to follow"
@ -29,12 +29,16 @@
"username": "Benutzername" "username": "Benutzername"
}, },
"nav": { "nav": {
"back": "Zurück",
"chat": "Lokaler Chat", "chat": "Lokaler Chat",
"friend_requests": "Followanfragen", "friend_requests": "Followanfragen",
"mentions": "Erwähnungen", "mentions": "Erwähnungen",
"public_tl": "Lokale Zeitleiste", "dms": "Direktnachrichten",
"public_tl": "Öffentliche Zeitleiste",
"timeline": "Zeitleiste", "timeline": "Zeitleiste",
"twkn": "Das gesamte bekannte Netzwerk" "twkn": "Das gesamte bekannte Netzwerk",
"user_search": "Benutzersuche",
"preferences": "Voreinstellungen"
}, },
"notifications": { "notifications": {
"broken_favorite": "Unbekannte Nachricht, suche danach...", "broken_favorite": "Unbekannte Nachricht, suche danach...",
@ -46,6 +50,7 @@
"repeated_you": "wiederholte deine Nachricht" "repeated_you": "wiederholte deine Nachricht"
}, },
"post_status": { "post_status": {
"new_status": "Neuen Status veröffentlichen",
"account_not_locked_warning": "Dein Profil ist nicht {0}. Wer dir folgen will, kann das jederzeit tun und dann auch deine privaten Beiträge sehen.", "account_not_locked_warning": "Dein Profil ist nicht {0}. Wer dir folgen will, kann das jederzeit tun und dann auch deine privaten Beiträge sehen.",
"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",
@ -69,7 +74,17 @@
"fullname": "Angezeigter Name", "fullname": "Angezeigter Name",
"password_confirm": "Passwort bestätigen", "password_confirm": "Passwort bestätigen",
"registration": "Registrierung", "registration": "Registrierung",
"token": "Einladungsschlüssel" "token": "Einladungsschlüssel",
"captcha": "CAPTCHA",
"new_captcha": "Zum Erstellen eines neuen Captcha auf das Bild klicken.",
"validations": {
"username_required": "darf nicht leer sein",
"fullname_required": "darf nicht leer sein",
"email_required": "darf nicht leer sein",
"password_required": "darf nicht leer sein",
"password_confirmation_required": "darf nicht leer sein",
"password_confirmation_match": "sollte mit dem Passwort identisch sein."
}
}, },
"settings": { "settings": {
"attachmentRadius": "Anhänge", "attachmentRadius": "Anhänge",
@ -89,6 +104,7 @@
"change_password_error": "Es gab ein Problem bei der Änderung des Passworts.", "change_password_error": "Es gab ein Problem bei der Änderung des Passworts.",
"changed_password": "Passwort erfolgreich geändert!", "changed_password": "Passwort erfolgreich geändert!",
"collapse_subject": "Beiträge mit Betreff einklappen", "collapse_subject": "Beiträge mit Betreff einklappen",
"composing": "Verfassen",
"confirm_new_password": "Neues Passwort bestätigen", "confirm_new_password": "Neues Passwort bestätigen",
"current_avatar": "Dein derzeitiger Avatar", "current_avatar": "Dein derzeitiger Avatar",
"current_password": "Aktuelles Passwort", "current_password": "Aktuelles Passwort",
@ -112,12 +128,17 @@
"general": "Allgemein", "general": "Allgemein",
"hide_attachments_in_convo": "Anhänge in Unterhaltungen ausblenden", "hide_attachments_in_convo": "Anhänge in Unterhaltungen ausblenden",
"hide_attachments_in_tl": "Anhänge in der Zeitleiste ausblenden", "hide_attachments_in_tl": "Anhänge in der Zeitleiste ausblenden",
"hide_isp": "Instanz-spezifisches Panel ausblenden",
"preload_images": "Bilder vorausladen",
"hide_post_stats": "Beitragsstatistiken verbergen (z.B. die Anzahl der Favoriten)", "hide_post_stats": "Beitragsstatistiken verbergen (z.B. die Anzahl der Favoriten)",
"hide_user_stats": "Benutzerstatistiken verbergen (z.B. die Anzahl der Follower)", "hide_user_stats": "Benutzerstatistiken verbergen (z.B. die Anzahl der Follower)",
"import_followers_from_a_csv_file": "Importiere Follower, denen du folgen möchtest, aus einer CSV-Datei", "import_followers_from_a_csv_file": "Importiere Follower, denen du folgen möchtest, aus einer CSV-Datei",
"import_theme": "Farbschema laden", "import_theme": "Farbschema laden",
"inputRadius": "Eingabefelder", "inputRadius": "Eingabefelder",
"checkboxRadius": "Auswahlfelder",
"instance_default": "(Standard: {value})", "instance_default": "(Standard: {value})",
"instance_default_simple": "(Standard)",
"interface": "Oberfläche",
"interfaceLanguage": "Sprache der Oberfläche", "interfaceLanguage": "Sprache der Oberfläche",
"invalid_theme_imported": "Die ausgewählte Datei ist kein unterstütztes Pleroma-Theme. Keine Änderungen wurden vorgenommen.", "invalid_theme_imported": "Die ausgewählte Datei ist kein unterstütztes Pleroma-Theme. Keine Änderungen wurden vorgenommen.",
"limited_availability": "In deinem Browser nicht verfügbar", "limited_availability": "In deinem Browser nicht verfügbar",
@ -134,6 +155,7 @@
"notification_visibility_mentions": "Erwähnungen", "notification_visibility_mentions": "Erwähnungen",
"notification_visibility_repeats": "Wiederholungen", "notification_visibility_repeats": "Wiederholungen",
"no_rich_text_description": "Rich-Text Formatierungen von allen Beiträgen entfernen", "no_rich_text_description": "Rich-Text Formatierungen von allen Beiträgen entfernen",
"hide_network_description": "Zeige nicht, wem ich folge und wer mir folgt",
"nsfw_clickthrough": "Aktiviere ausblendbares Overlay für Anhänge, die als NSFW markiert sind", "nsfw_clickthrough": "Aktiviere ausblendbares Overlay für Anhänge, die als NSFW markiert sind",
"panelRadius": "Panel", "panelRadius": "Panel",
"pause_on_unfocused": "Streaming pausieren, wenn das Tab nicht fokussiert ist", "pause_on_unfocused": "Streaming pausieren, wenn das Tab nicht fokussiert ist",
@ -150,20 +172,139 @@
"saving_err": "Fehler beim Speichern der Einstellungen", "saving_err": "Fehler beim Speichern der Einstellungen",
"saving_ok": "Einstellungen gespeichert", "saving_ok": "Einstellungen gespeichert",
"security_tab": "Sicherheit", "security_tab": "Sicherheit",
"scope_copy": "Reichweite beim Antworten übernehmen (Direktnachrichten werden immer kopiert)",
"set_new_avatar": "Setze einen neuen Avatar", "set_new_avatar": "Setze einen neuen Avatar",
"set_new_profile_background": "Setze einen neuen Hintergrund für dein Profil", "set_new_profile_background": "Setze einen neuen Hintergrund für dein Profil",
"set_new_profile_banner": "Setze einen neuen Banner für dein Profil", "set_new_profile_banner": "Setze einen neuen Banner für dein Profil",
"settings": "Einstellungen", "settings": "Einstellungen",
"subject_input_always_show": "Betreff-Feld immer anzeigen",
"subject_line_behavior": "Betreff beim Antworten kopieren",
"subject_line_email": "Wie Email: \"re: Betreff\"",
"subject_line_mastodon": "Wie Mastodon: unverändert kopieren",
"subject_line_noop": "Nicht kopieren",
"stop_gifs": "Play-on-hover GIFs", "stop_gifs": "Play-on-hover GIFs",
"streaming": "Aktiviere automatisches Laden (Streaming) von neuen Beiträgen", "streaming": "Aktiviere automatisches Laden (Streaming) von neuen Beiträgen",
"text": "Text", "text": "Text",
"theme": "Farbschema", "theme": "Farbschema",
"theme_help": "Benutze HTML-Farbcodes (#rrggbb) um dein Farbschema anzupassen", "theme_help": "Benutze HTML-Farbcodes (#rrggbb) um dein Farbschema anzupassen",
"theme_help_v2_1": "Du kannst auch die Farben und die Deckkraft bestimmter Komponenten überschreiben, indem du das Kontrollkästchen umschaltest. Verwende die Schaltfläche \"Alle löschen\", um alle Überschreibungen zurückzusetzen.",
"theme_help_v2_2": "Unter einigen Einträgen befinden sich Symbole für Hintergrund-/Textkontrastindikatoren, für detaillierte Informationen fahre mit der Maus darüber. Bitte beachte, dass bei der Verwendung von Transparenz Kontrastindikatoren den schlechtest möglichen Fall darstellen.",
"tooltipRadius": "Tooltips/Warnungen", "tooltipRadius": "Tooltips/Warnungen",
"user_settings": "Benutzereinstellungen", "user_settings": "Benutzereinstellungen",
"values": { "values": {
"false": "nein", "false": "nein",
"true": "Ja" "true": "Ja"
},
"notifications": "Benachrichtigungen",
"enable_web_push_notifications": "Web-Pushbenachrichtigungen aktivieren",
"style": {
"switcher": {
"keep_color": "Farben beibehalten",
"keep_shadows": "Schatten beibehalten",
"keep_opacity": "Deckkraft beibehalten",
"keep_roundness": "Abrundungen beibehalten",
"keep_fonts": "Schriften beibehalten",
"save_load_hint": "Die \"Beibehalten\"-Optionen behalten die aktuell eingestellten Optionen beim Auswählen oder Laden von Designs bei, sie speichern diese Optionen auch beim Exportieren eines Designs. Wenn alle Kontrollkästchen deaktiviert sind, wird beim Exportieren des Designs alles gespeichert.",
"reset": "Zurücksetzen",
"clear_all": "Alles leeren",
"clear_opacity": "Deckkraft leeren"
},
"common": {
"color": "Farbe",
"opacity": "Deckkraft",
"contrast": {
"hint": "Das Kontrastverhältnis ist {ratio}, es {level} {context}",
"level": {
"aa": "entspricht Level AA Richtlinie (minimum)",
"aaa": "entspricht Level AAA Richtlinie (empfohlen)",
"bad": "entspricht keiner Richtlinien zur Barrierefreiheit"
},
"context": {
"18pt": "für großen (18pt+) Text",
"text": "für Text"
}
}
},
"common_colors": {
"_tab_label": "Allgemein",
"main": "Allgemeine Farben",
"foreground_hint": "Siehe Reiter \"Erweitert\" für eine detailliertere Einstellungen",
"rgbo": "Symbole, Betonungen, Kennzeichnungen"
},
"advanced_colors": {
"_tab_label": "Erweitert",
"alert": "Warnhinweis-Hintergrund",
"alert_error": "Fehler",
"badge": "Kennzeichnungs-Hintergrund",
"badge_notification": "Benachrichtigung",
"panel_header": "Panel-Kopf",
"top_bar": "Obere Leiste",
"borders": "Rahmen",
"buttons": "Schaltflächen",
"inputs": "Eingabefelder",
"faint_text": "Verblasster Text"
},
"radii": {
"_tab_label": "Abrundungen"
},
"shadows": {
"_tab_label": "Schatten und Beleuchtung",
"component": "Komponente",
"override": "Überschreiben",
"shadow_id": "Schatten #{value}",
"blur": "Unschärfe",
"spread": "Streuung",
"inset": "Einsatz",
"hint": "Für Schatten kannst du auch --variable als Farbwert verwenden, um CSS3-Variablen zu verwenden. Bitte beachte, dass die Einstellung der Deckkraft in diesem Fall nicht funktioniert.",
"filter_hint": {
"always_drop_shadow": "Achtung, dieser Schatten verwendet immer {0}, wenn der Browser dies unterstützt.",
"drop_shadow_syntax": "{0} unterstützt Parameter {1} und Schlüsselwort {2} nicht.",
"avatar_inset": "Bitte beachte, dass die Kombination von eingesetzten und nicht eingesetzten Schatten auf Avataren zu unerwarteten Ergebnissen bei transparenten Avataren führen kann.",
"spread_zero": "Schatten mit einer Streuung > 0 erscheinen so, als ob sie auf Null gesetzt wären.",
"inset_classic": "Eingesetzte Schatten werden mit {0} verwendet"
},
"components": {
"panel": "Panel",
"panelHeader": "Panel-Kopf",
"topBar": "Obere Leiste",
"avatar": "Benutzer-Avatar (in der Profilansicht)",
"avatarStatus": "Benutzer-Avatar (in der Beitragsanzeige)",
"popup": "Dialogfenster und Hinweistexte",
"button": "Schaltfläche",
"buttonHover": "Schaltfläche (hover)",
"buttonPressed": "Schaltfläche (gedrückt)",
"buttonPressedHover": "Schaltfläche (gedrückt+hover)",
"input": "Input field"
}
},
"fonts": {
"_tab_label": "Schriften",
"help": "Wähl die Schriftart, die für Elemente der Benutzeroberfläche verwendet werden soll. Für \" Benutzerdefiniert\" musst du den genauen Schriftnamen eingeben, wie er im System angezeigt wird.",
"components": {
"interface": "Oberfläche",
"input": "Eingabefelder",
"post": "Beitragstext",
"postCode": "Dicktengleicher Text in einem Beitrag (Rich-Text)"
},
"family": "Schriftname",
"size": "Größe (in px)",
"weight": "Gewicht (Dicke)",
"custom": "Benutzerdefiniert"
},
"preview": {
"header": "Vorschau",
"content": "Inhalt",
"error": "Beispielfehler",
"button": "Schaltfläche",
"text": "Ein Haufen mehr von {0} und {1}",
"mono": "Inhalt",
"input": "Sitze gerade im Hofbräuhaus.",
"faint_link": "Hilfreiche Anleitung",
"fine_print": "Lies unser {0}, um nichts Nützliches zu lernen!",
"header_faint": "Das ist in Ordnung",
"checkbox": "Ich habe die Allgemeinen Geschäftsbedingungen überflogen",
"link": "ein netter kleiner Link"
}
} }
}, },
"timeline": { "timeline": {
@ -182,10 +323,15 @@
"blocked": "Blockiert!", "blocked": "Blockiert!",
"deny": "Ablehnen", "deny": "Ablehnen",
"follow": "Folgen", "follow": "Folgen",
"follow_sent": "Anfrage gesendet!",
"follow_progress": "Anfragen…",
"follow_again": "Anfrage erneut senden?",
"follow_unfollow": "Folgen beenden",
"followees": "Folgt", "followees": "Folgt",
"followers": "Followers", "followers": "Followers",
"following": "Folgst du!", "following": "Folgst du!",
"follows_you": "Folgt dir!", "follows_you": "Folgt dir!",
"its_you": "Das bist du!",
"mute": "Stummschalten", "mute": "Stummschalten",
"muted": "Stummgeschaltet", "muted": "Stummgeschaltet",
"per_day": "pro Tag", "per_day": "pro Tag",
@ -198,5 +344,26 @@
"who_to_follow": { "who_to_follow": {
"more": "Mehr", "more": "Mehr",
"who_to_follow": "Wem soll ich folgen" "who_to_follow": "Wem soll ich folgen"
},
"tool_tip": {
"media_upload": "Medien hochladen",
"repeat": "Wiederholen",
"reply": "Antworten",
"favorite": "Favorisieren",
"user_settings": "Benutzereinstellungen"
},
"upload":{
"error": {
"base": "Hochladen fehlgeschlagen.",
"file_too_big": "Datei ist zu groß [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]",
"default": "Bitte versuche es später erneut"
},
"file_size_units": {
"B": "B",
"KiB": "KiB",
"MiB": "MiB",
"GiB": "GiB",
"TiB": "TiB"
}
} }
} }

View file

@ -50,6 +50,7 @@
"repeated_you": "repeated your status" "repeated_you": "repeated your status"
}, },
"post_status": { "post_status": {
"new_status": "Post new status",
"account_not_locked_warning": "Your account is not {0}. Anyone can follow you to view your follower-only posts.", "account_not_locked_warning": "Your account is not {0}. Anyone can follow you to view your follower-only posts.",
"account_not_locked_warning_link": "locked", "account_not_locked_warning_link": "locked",
"attachments_sensitive": "Mark attachments as sensitive", "attachments_sensitive": "Mark attachments as sensitive",
@ -74,6 +75,8 @@
"password_confirm": "Password confirmation", "password_confirm": "Password confirmation",
"registration": "Registration", "registration": "Registration",
"token": "Invite token", "token": "Invite token",
"captcha": "CAPTCHA",
"new_captcha": "Click the image to get a new captcha",
"validations": { "validations": {
"username_required": "cannot be left blank", "username_required": "cannot be left blank",
"fullname_required": "cannot be left blank", "fullname_required": "cannot be left blank",

View file

@ -29,13 +29,16 @@
"username": "ユーザーめい" "username": "ユーザーめい"
}, },
"nav": { "nav": {
"back": "もどる",
"chat": "ローカルチャット", "chat": "ローカルチャット",
"friend_requests": "フォローリクエスト", "friend_requests": "フォローリクエスト",
"mentions": "メンション", "mentions": "メンション",
"dms": "ダイレクトメッセージ", "dms": "ダイレクトメッセージ",
"public_tl": "パブリックタイムライン", "public_tl": "パブリックタイムライン",
"timeline": "タイムライン", "timeline": "タイムライン",
"twkn": "つながっているすべてのネットワーク" "twkn": "つながっているすべてのネットワーク",
"user_search": "ユーザーをさがす",
"preferences": "せってい"
}, },
"notifications": { "notifications": {
"broken_favorite": "ステータスがみつかりません。さがしています...", "broken_favorite": "ステータスがみつかりません。さがしています...",
@ -70,7 +73,17 @@
"fullname": "スクリーンネーム", "fullname": "スクリーンネーム",
"password_confirm": "パスワードのかくにん", "password_confirm": "パスワードのかくにん",
"registration": "はじめる", "registration": "はじめる",
"token": "しょうたいトークン" "token": "しょうたいトークン",
"captcha": "CAPTCHA",
"new_captcha": "もじがよめないときは、がぞうをクリックすると、あたらしいがぞうになります",
"validations": {
"username_required": "なにかかいてください",
"fullname_required": "なにかかいてください",
"email_required": "なにかかいてください",
"password_required": "なにかかいてください",
"password_confirmation_required": "なにかかいてください",
"password_confirmation_match": "パスワードがちがいます"
}
}, },
"settings": { "settings": {
"attachmentRadius": "ファイル", "attachmentRadius": "ファイル",
@ -90,6 +103,7 @@
"change_password_error": "パスワードをかえることが、できなかったかもしれません。", "change_password_error": "パスワードをかえることが、できなかったかもしれません。",
"changed_password": "パスワードが、かわりました!", "changed_password": "パスワードが、かわりました!",
"collapse_subject": "せつめいのあるとうこうをたたむ", "collapse_subject": "せつめいのあるとうこうをたたむ",
"composing": "とうこう",
"confirm_new_password": "あたらしいパスワードのかくにん", "confirm_new_password": "あたらしいパスワードのかくにん",
"current_avatar": "いまのアバター", "current_avatar": "いまのアバター",
"current_password": "いまのパスワード", "current_password": "いまのパスワード",
@ -113,17 +127,22 @@
"general": "ぜんぱん", "general": "ぜんぱん",
"hide_attachments_in_convo": "スレッドのファイルをかくす", "hide_attachments_in_convo": "スレッドのファイルをかくす",
"hide_attachments_in_tl": "タイムラインのファイルをかくす", "hide_attachments_in_tl": "タイムラインのファイルをかくす",
"hide_isp": "インスタンススペシフィックパネルをかくす",
"preload_images": "がぞうをさきよみする",
"hide_post_stats": "とうこうのとうけいをかくす (れい: おきにいりのかず)", "hide_post_stats": "とうこうのとうけいをかくす (れい: おきにいりのかず)",
"hide_user_stats": "ユーザーのとうけいをかくす (れい: フォロワーのかず)", "hide_user_stats": "ユーザーのとうけいをかくす (れい: フォロワーのかず)",
"import_followers_from_a_csv_file": "CSVファイルからフォローをインポートする", "import_followers_from_a_csv_file": "CSVファイルからフォローをインポートする",
"import_theme": "ロード", "import_theme": "ロード",
"inputRadius": "インプットフィールド", "inputRadius": "インプットフィールド",
"checkboxRadius": "チェックボックス",
"instance_default": "(デフォルト: {value})", "instance_default": "(デフォルト: {value})",
"instance_default_simple": "(デフォルト)",
"interface": "インターフェース",
"interfaceLanguage": "インターフェースのことば", "interfaceLanguage": "インターフェースのことば",
"invalid_theme_imported": "このファイルはPleromaのテーマではありません。テーマはへんこうされませんでした。", "invalid_theme_imported": "このファイルはPleromaのテーマではありません。テーマはへんこうされませんでした。",
"limited_availability": "あなたのブラウザではできません", "limited_availability": "あなたのブラウザではできません",
"links": "リンク", "links": "リンク",
"lock_account_description": "あなたがみとめたひとだけ、あなたのアカウントをフォローできます", "lock_account_description": "あなたがみとめたひとだけ、あなたのアカウントをフォローでき",
"loop_video": "ビデオをくりかえす", "loop_video": "ビデオをくりかえす",
"loop_video_silent_only": "おとのないビデオだけくりかえす", "loop_video_silent_only": "おとのないビデオだけくりかえす",
"name": "なまえ", "name": "なまえ",
@ -135,6 +154,7 @@
"notification_visibility_mentions": "メンション", "notification_visibility_mentions": "メンション",
"notification_visibility_repeats": "リピート", "notification_visibility_repeats": "リピート",
"no_rich_text_description": "リッチテキストをつかわない", "no_rich_text_description": "リッチテキストをつかわない",
"hide_network_description": "わたしがフォローしているひとと、わたしをフォローしているひとを、みせない",
"nsfw_clickthrough": "NSFWなファイルをかくす", "nsfw_clickthrough": "NSFWなファイルをかくす",
"panelRadius": "パネル", "panelRadius": "パネル",
"pause_on_unfocused": "タブにフォーカスがないときストリーミングをとめる", "pause_on_unfocused": "タブにフォーカスがないときストリーミングをとめる",
@ -151,20 +171,139 @@
"saving_err": "せっていをセーブできませんでした", "saving_err": "せっていをセーブできませんでした",
"saving_ok": "せっていをセーブしました", "saving_ok": "せっていをセーブしました",
"security_tab": "セキュリティ", "security_tab": "セキュリティ",
"scope_copy": "リプライするとき、こうかいはんいをコピーする (DMのこうかいはんいは、つねにコピーされます)",
"set_new_avatar": "あたらしいアバターをせっていする", "set_new_avatar": "あたらしいアバターをせっていする",
"set_new_profile_background": "あたらしいプロフィールのバックグラウンドをせっていする", "set_new_profile_background": "あたらしいプロフィールのバックグラウンドをせっていする",
"set_new_profile_banner": "あたらしいプロフィールバナーを設定する", "set_new_profile_banner": "あたらしいプロフィールバナーを設定する",
"settings": "せってい", "settings": "せってい",
"subject_input_always_show": "サブジェクトフィールドをいつでもひょうじする",
"subject_line_behavior": "リプライするときサブジェクトをコピーする",
"subject_line_email": "メールふう: \"re: サブジェクト\"",
"subject_line_mastodon": "マストドンふう: そのままコピー",
"subject_line_noop": "コピーしない",
"stop_gifs": "カーソルをかさねたとき、GIFをうごかす", "stop_gifs": "カーソルをかさねたとき、GIFをうごかす",
"streaming": "うえまでスクロールしたとき、じどうてきにストリーミングする", "streaming": "うえまでスクロールしたとき、じどうてきにストリーミングする",
"text": "もじ", "text": "もじ",
"theme": "テーマ", "theme": "テーマ",
"theme_help": "カラーテーマをカスタマイズできます", "theme_help": "カラーテーマをカスタマイズできます",
"theme_help_v2_1": "チェックボックスをONにすると、コンポーネントごとに、いろと、とうめいどを、オーバーライドできます。「すべてクリア」ボタンをおすと、すべてのオーバーライドを、やめます。",
"theme_help_v2_2": "バックグラウンドとテキストのコントラストをあらわすアイコンがあります。マウスをホバーすると、くわしいせつめいがでます。とうめいないろをつかっているときは、もっともわるいばあいのコントラストがしめされます。",
"tooltipRadius": "ツールチップとアラート", "tooltipRadius": "ツールチップとアラート",
"user_settings": "ユーザーせってい", "user_settings": "ユーザーせってい",
"values": { "values": {
"false": "いいえ", "false": "いいえ",
"true": "はい" "true": "はい"
},
"notifications": "つうち",
"enable_web_push_notifications": "ウェブプッシュつうちをゆるす",
"style": {
"switcher": {
"keep_color": "いろをのこす",
"keep_shadows": "かげをのこす",
"keep_opacity": "とうめいどをのこす",
"keep_roundness": "まるさをのこす",
"keep_fonts": "フォントをのこす",
"save_load_hint": "「のこす」オプションをONにすると、テーマをえらんだときとロードしたとき、いまのせっていをのこします。また、テーマをエクスポートするとき、これらのオプションをストアします。すべてのチェックボックスをOFFにすると、テーマをエクスポートしたとき、すべてのせっていをセーブします。",
"reset": "リセット",
"clear_all": "すべてクリア",
"clear_opacity": "とうめいどをクリア"
},
"common": {
"color": "いろ",
"opacity": "とうめいど",
"contrast": {
"hint": "コントラストは {ratio} です。{level}。({context})",
"level": {
"aa": "AAレベルガイドライン (ミニマル) をみたします",
"aaa": "AAAレベルガイドライン (レコメンデッド) をみたします。",
"bad": "ガイドラインをみたしません。"
},
"context": {
"18pt": "おおきい (18ポイントいじょう) テキスト",
"text": "テキスト"
}
}
},
"common_colors": {
"_tab_label": "きょうつう",
"main": "きょうつうのいろ",
"foreground_hint": "「くわしく」タブで、もっとこまかくせっていできます",
"rgbo": "アイコンとアクセントとバッジ"
},
"advanced_colors": {
"_tab_label": "くわしく",
"alert": "アラートのバックグラウンド",
"alert_error": "エラー",
"badge": "バッジのバックグラウンド",
"badge_notification": "つうち",
"panel_header": "パネルヘッダー",
"top_bar": "トップバー",
"borders": "さかいめ",
"buttons": "ボタン",
"inputs": "インプットフィールド",
"faint_text": "うすいテキスト"
},
"radii": {
"_tab_label": "まるさ"
},
"shadows": {
"_tab_label": "ひかりとかげ",
"component": "コンポーネント",
"override": "オーバーライド",
"shadow_id": "かげ #{value}",
"blur": "ぼかし",
"spread": "ひろがり",
"inset": "うちがわ",
"hint": "かげのせっていでは、いろのあたいとして --variable をつかうことができます。これはCSS3へんすうです。ただし、とうめいどのせっていは、きかなくなります。",
"filter_hint": {
"always_drop_shadow": "ブラウザーがサポートしていれば、つねに {0} がつかわれます。",
"drop_shadow_syntax": "{0} は、{1} パラメーターと {2} キーワードをサポートしていません。",
"avatar_inset": "うちがわのかげと、そとがわのかげを、いっしょにつかうと、とうめいなアバターが、へんなみためになります。",
"spread_zero": "ひろがりが 0 よりもおおきなかげは、0 とおなじです。",
"inset_classic": "うちがわのかげは {0} をつかいます。"
},
"components": {
"panel": "パネル",
"panelHeader": "パネルヘッダー",
"topBar": "トップバー",
"avatar": "ユーザーアバター (プロフィール)",
"avatarStatus": "ユーザーアバター (とうこう)",
"popup": "ポップアップとツールチップ",
"button": "ボタン",
"buttonHover": "ボタン (ホバー)",
"buttonPressed": "ボタン (おされているとき)",
"buttonPressedHover": "ボタン (ホバー、かつ、おされているとき)",
"input": "インプットフィールド"
}
},
"fonts": {
"_tab_label": "フォント",
"help": "「カスタム」をえらんだときは、システムにあるフォントのなまえを、ただしくにゅうりょくしてください。",
"components": {
"interface": "インターフェース",
"input": "インプットフィールド",
"post": "とうこう",
"postCode": "モノスペース (とうこうがリッチテキストであるとき)"
},
"family": "フォントめい",
"size": "おおきさ (px)",
"weight": "ふとさ",
"custom": "カスタム"
},
"preview": {
"header": "プレビュー",
"content": "ほんぶん",
"error": "エラーのれい",
"button": "ボタン",
"text": "これは{0}と{1}のれいです。",
"mono": "monospace",
"input": "はねだくうこうに、つきました。",
"faint_link": "とてもたすけになるマニュアル",
"fine_print": "わたしたちの{0}を、よまないでください!",
"header_faint": "エラーではありません",
"checkbox": "りようきやくを、よみました",
"link": "ハイパーリンク"
}
} }
}, },
"timeline": { "timeline": {
@ -183,10 +322,15 @@
"blocked": "ブロックしています!", "blocked": "ブロックしています!",
"deny": "おことわり", "deny": "おことわり",
"follow": "フォロー", "follow": "フォロー",
"follow_sent": "リクエストを、おくりました!",
"follow_progress": "リクエストしています…",
"follow_again": "ふたたびリクエストをおくりますか?",
"follow_unfollow": "フォローをやめる",
"followees": "フォロー", "followees": "フォロー",
"followers": "フォロワー", "followers": "フォロワー",
"following": "フォローしています!", "following": "フォローしています!",
"follows_you": "フォローされました!", "follows_you": "フォローされました!",
"its_you": "これはあなたです!",
"mute": "ミュート", "mute": "ミュート",
"muted": "ミュートしています!", "muted": "ミュートしています!",
"per_day": "/日", "per_day": "/日",
@ -199,5 +343,26 @@
"who_to_follow": { "who_to_follow": {
"more": "くわしく", "more": "くわしく",
"who_to_follow": "おすすめユーザー" "who_to_follow": "おすすめユーザー"
},
"tool_tip": {
"media_upload": "メディアをアップロード",
"repeat": "リピート",
"reply": "リプライ",
"favorite": "おきにいり",
"user_settings": "ユーザーせってい"
},
"upload":{
"error": {
"base": "アップロードにしっぱいしました。",
"file_too_big": "ファイルがおおきすぎます [{filesize} {filesizeunit} / {allowedsize} {allowedsizeunit}]",
"default": "しばらくしてから、ためしてください"
},
"file_size_units": {
"B": "B",
"KiB": "KiB",
"MiB": "MiB",
"GiB": "GiB",
"TiB": "TiB"
}
} }
} }

View file

@ -0,0 +1,22 @@
export default (store) => {
store.subscribe((mutation, state) => {
const vapidPublicKey = state.instance.vapidPublicKey
const webPushNotification = state.config.webPushNotifications
const permission = state.interface.notificationPermission === 'granted'
const user = state.users.currentUser
const isUserMutation = mutation.type === 'setCurrentUser'
const isVapidMutation = mutation.type === 'setInstanceOption' && mutation.payload.name === 'vapidPublicKey'
const isPermMutation = mutation.type === 'setNotificationPermission' && mutation.payload === 'granted'
const isUserConfigMutation = mutation.type === 'setOption' && mutation.payload.name === 'webPushNotifications'
const isVisibilityMutation = mutation.type === 'setOption' && mutation.payload.name === 'notificationVisibility'
if (isUserMutation || isVapidMutation || isPermMutation || isUserConfigMutation || isVisibilityMutation) {
if (user && vapidPublicKey && permission && webPushNotification) {
return store.dispatch('registerPushNotifications')
} else if (isUserConfigMutation && !webPushNotification) {
return store.dispatch('unregisterPushNotifications')
}
}
})
}

View file

@ -15,6 +15,7 @@ import VueTimeago from 'vue-timeago'
import VueI18n from 'vue-i18n' import VueI18n from 'vue-i18n'
import createPersistedState from './lib/persisted_state.js' import createPersistedState from './lib/persisted_state.js'
import pushNotifications from './lib/push_notifications_plugin.js'
import messages from './i18n/messages.js' import messages from './i18n/messages.js'
@ -51,31 +52,6 @@ const persistedStateOptions = {
] ]
} }
const registerPushNotifications = store => {
store.subscribe((mutation, state) => {
const vapidPublicKey = state.instance.vapidPublicKey
const permission = state.interface.notificationPermission === 'granted'
const isUserMutation = mutation.type === 'setCurrentUser'
if (isUserMutation && vapidPublicKey && permission) {
return store.dispatch('registerPushNotifications')
}
const user = state.users.currentUser
const isVapidMutation = mutation.type === 'setInstanceOption' && mutation.payload.name === 'vapidPublicKey'
if (isVapidMutation && user && permission) {
return store.dispatch('registerPushNotifications')
}
const isPermMutation = mutation.type === 'setNotificationPermission' && mutation.payload === 'granted'
if (isPermMutation && user && vapidPublicKey) {
return store.dispatch('registerPushNotifications')
}
})
}
createPersistedState(persistedStateOptions).then((persistedState) => { createPersistedState(persistedStateOptions).then((persistedState) => {
const store = new Vuex.Store({ const store = new Vuex.Store({
modules: { modules: {
@ -88,7 +64,7 @@ createPersistedState(persistedStateOptions).then((persistedState) => {
chat: chatModule, chat: chatModule,
oauth: oauthModule oauth: oauthModule
}, },
plugins: [persistedState, registerPushNotifications], plugins: [persistedState, pushNotifications],
strict: false // Socket modifies itself, let's ignore this for now. strict: false // Socket modifies itself, let's ignore this for now.
// strict: process.env.NODE_ENV !== 'production' // strict: process.env.NODE_ENV !== 'production'
}) })

View file

@ -24,7 +24,7 @@ const defaultState = {
likes: true, likes: true,
repeats: true repeats: true
}, },
webPushNotifications: true, webPushNotifications: false,
muteWords: [], muteWords: [],
highlight: {}, highlight: {},
interfaceLanguage: browserLocale, interfaceLanguage: browserLocale,

View file

@ -12,8 +12,8 @@ const defaultState = {
logo: '/static/logo.png', logo: '/static/logo.png',
logoMask: true, logoMask: true,
logoMargin: '.2em', logoMargin: '.2em',
redirectRootNoLogin: '/~/main/all', redirectRootNoLogin: '/main/all',
redirectRootLogin: '/~/main/friends', redirectRootLogin: '/main/friends',
showInstanceSpecificPanel: false, showInstanceSpecificPanel: false,
scopeOptionsEnabled: true, scopeOptionsEnabled: true,
formattingOptionsEnabled: false, formattingOptionsEnabled: false,
@ -27,11 +27,13 @@ const defaultState = {
loginMethod: 'password', loginMethod: 'password',
nsfwCensorImage: undefined, nsfwCensorImage: undefined,
vapidPublicKey: undefined, vapidPublicKey: undefined,
noAttachmentLinks: false,
// Nasty stuff // Nasty stuff
pleromaBackend: true, pleromaBackend: true,
emoji: [], emoji: [],
customEmoji: [], customEmoji: [],
restrictedNicknames: [],
// Feature-set, apparently, not everything here is reported... // Feature-set, apparently, not everything here is reported...
mediaProxyAvailable: false, mediaProxyAvailable: false,

View file

@ -27,6 +27,7 @@ export const defaultState = {
maxId: 0, maxId: 0,
minId: Number.POSITIVE_INFINITY, minId: Number.POSITIVE_INFINITY,
data: [], data: [],
idStore: {},
error: false error: false
}, },
favorites: new Set(), favorites: new Set(),
@ -307,6 +308,7 @@ const addNewNotifications = (state, { dispatch, notifications, older, visibleNot
} }
state.notifications.data.push(result) state.notifications.data.push(result)
state.notifications.idStore[notification.id] = result
if ('Notification' in window && window.Notification.permission === 'granted') { if ('Notification' in window && window.Notification.permission === 'granted') {
const title = action.user.name const title = action.user.name

View file

@ -1,7 +1,7 @@
import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js' import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
import { compact, map, each, merge } from 'lodash' import { compact, map, each, merge } from 'lodash'
import { set } from 'vue' import { set } from 'vue'
import registerPushNotifications from '../services/push/push.js' import { registerPushNotifications, unregisterPushNotifications } from '../services/push/push.js'
import oauthApi from '../services/new_api/oauth' import oauthApi from '../services/new_api/oauth'
import { humanizeErrors } from './errors' import { humanizeErrors } from './errors'
@ -66,6 +66,9 @@ export const mutations = {
setUserForStatus (state, status) { setUserForStatus (state, status) {
status.user = state.usersObject[status.user.id] status.user = state.usersObject[status.user.id]
}, },
setUserForNotification (state, notification) {
notification.action.user = state.usersObject[notification.action.user.id]
},
setColor (state, { user: { id }, highlighted }) { setColor (state, { user: { id }, highlighted }) {
const user = state.usersObject[id] const user = state.usersObject[id]
set(user, 'highlight', highlighted) set(user, 'highlight', highlighted)
@ -83,6 +86,13 @@ export const mutations = {
} }
} }
export const getters = {
userById: state => id =>
state.users.find(user => user.id === id),
userByName: state => name =>
state.users.find(user => user.screen_name === name)
}
export const defaultState = { export const defaultState = {
loggingIn: false, loggingIn: false,
lastLoginName: false, lastLoginName: false,
@ -96,6 +106,7 @@ export const defaultState = {
const users = { const users = {
state: defaultState, state: defaultState,
mutations, mutations,
getters,
actions: { actions: {
fetchUser (store, id) { fetchUser (store, id) {
store.rootState.api.backendInteractor.fetchUser({ id }) store.rootState.api.backendInteractor.fetchUser({ id })
@ -113,8 +124,14 @@ const users = {
const token = store.state.currentUser.credentials const token = store.state.currentUser.credentials
const vapidPublicKey = store.rootState.instance.vapidPublicKey const vapidPublicKey = store.rootState.instance.vapidPublicKey
const isEnabled = store.rootState.config.webPushNotifications const isEnabled = store.rootState.config.webPushNotifications
const notificationVisibility = store.rootState.config.notificationVisibility
registerPushNotifications(isEnabled, vapidPublicKey, token) registerPushNotifications(isEnabled, vapidPublicKey, token, notificationVisibility)
},
unregisterPushNotifications (store) {
const token = store.state.currentUser.credentials
unregisterPushNotifications(token)
}, },
addNewStatuses (store, { statuses }) { addNewStatuses (store, { statuses }) {
const users = map(statuses, 'user') const users = map(statuses, 'user')
@ -131,6 +148,21 @@ const users = {
store.commit('setUserForStatus', status) store.commit('setUserForStatus', status)
}) })
}, },
addNewNotifications (store, { notifications }) {
const users = compact(map(notifications, 'from_profile'))
const notificationIds = compact(notifications.map(_ => String(_.id)))
store.commit('addNewUsers', users)
const notificationsObject = store.rootState.statuses.notifications.idStore
const relevantNotifications = Object.entries(notificationsObject)
.filter(([k, val]) => notificationIds.includes(k))
.map(([k, val]) => val)
// Reconnect users to notifications
each(relevantNotifications, (notification) => {
store.commit('setUserForNotification', notification)
})
},
async signUp (store, userInfo) { async signUp (store, userInfo) {
store.commit('signUpPending') store.commit('signUpPending')

View file

@ -370,12 +370,13 @@ const unretweet = ({ id, credentials }) => {
}) })
} }
const postStatus = ({credentials, status, spoilerText, visibility, sensitive, mediaIds, inReplyToStatusId}) => { const postStatus = ({credentials, status, spoilerText, visibility, sensitive, mediaIds, inReplyToStatusId, contentType, noAttachmentLinks}) => {
const idsText = mediaIds.join(',') 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)

View file

@ -5,7 +5,7 @@ const getOrCreateApp = ({oauth, instance}) => {
const form = new window.FormData() const form = new window.FormData()
form.append('client_name', `PleromaFE_${Math.random()}`) form.append('client_name', `PleromaFE_${Math.random()}`)
form.append('redirect_uris', `${window.location.origin}/~/oauth-callback`) form.append('redirect_uris', `${window.location.origin}/oauth-callback`)
form.append('scopes', 'read write follow') form.append('scopes', 'read write follow')
return window.fetch(url, { return window.fetch(url, {
@ -64,7 +64,7 @@ const getToken = ({app, instance, code}) => {
form.append('client_secret', app.client_secret) form.append('client_secret', app.client_secret)
form.append('grant_type', 'authorization_code') form.append('grant_type', 'authorization_code')
form.append('code', code) form.append('code', code)
form.append('redirect_uri', `${window.location.origin}/~/oauth-callback`) form.append('redirect_uri', `${window.location.origin}/oauth-callback`)
return window.fetch(url, { return window.fetch(url, {
method: 'POST', method: 'POST',

View file

@ -0,0 +1,20 @@
import { filter, sortBy } from 'lodash'
export const notificationsFromStore = store => store.state.statuses.notifications.data
export const visibleTypes = store => ([
store.state.config.notificationVisibility.likes && 'like',
store.state.config.notificationVisibility.mentions && 'mention',
store.state.config.notificationVisibility.repeats && 'repeat',
store.state.config.notificationVisibility.follows && 'follow'
].filter(_ => _))
export const visibleNotificationsFromStore = store => {
// Don't know why, but sortBy([seen, -action.id]) doesn't work.
let sortedNotifications = sortBy(notificationsFromStore(store), ({action}) => -action.id)
sortedNotifications = sortBy(sortedNotifications, 'seen')
return sortedNotifications.filter((notification) => visibleTypes(store).includes(notification.type))
}
export const unseenNotificationsFromStore = store =>
filter(visibleNotificationsFromStore(store), ({seen}) => !seen)

View file

@ -14,12 +14,12 @@ function isPushSupported () {
return 'serviceWorker' in navigator && 'PushManager' in window return 'serviceWorker' in navigator && 'PushManager' in window
} }
function registerServiceWorker () { function getOrCreateServiceWorker () {
return runtime.register() return runtime.register()
.catch((err) => console.error('Unable to register service worker.', err)) .catch((err) => console.error('Unable to get or create a service worker.', err))
} }
function subscribe (registration, isEnabled, vapidPublicKey) { function subscribePush (registration, isEnabled, vapidPublicKey) {
if (!isEnabled) return Promise.reject(new Error('Web Push is disabled in config')) if (!isEnabled) return Promise.reject(new Error('Web Push is disabled in config'))
if (!vapidPublicKey) return Promise.reject(new Error('VAPID public key is not found')) if (!vapidPublicKey) return Promise.reject(new Error('VAPID public key is not found'))
@ -30,7 +30,28 @@ function subscribe (registration, isEnabled, vapidPublicKey) {
return registration.pushManager.subscribe(subscribeOptions) return registration.pushManager.subscribe(subscribeOptions)
} }
function sendSubscriptionToBackEnd (subscription, token) { function unsubscribePush (registration) {
return registration.pushManager.getSubscription()
.then((subscribtion) => {
if (subscribtion === null) { return }
return subscribtion.unsubscribe()
})
}
function deleteSubscriptionFromBackEnd (token) {
return window.fetch('/api/v1/push/subscription/', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
}
}).then((response) => {
if (!response.ok) throw new Error('Bad status code from server.')
return response
})
}
function sendSubscriptionToBackEnd (subscription, token, notificationVisibility) {
return window.fetch('/api/v1/push/subscription/', { return window.fetch('/api/v1/push/subscription/', {
method: 'POST', method: 'POST',
headers: { headers: {
@ -41,29 +62,49 @@ function sendSubscriptionToBackEnd (subscription, token) {
subscription, subscription,
data: { data: {
alerts: { alerts: {
follow: true, follow: notificationVisibility.follows,
favourite: true, favourite: notificationVisibility.likes,
mention: true, mention: notificationVisibility.mentions,
reblog: true reblog: notificationVisibility.repeats
} }
} }
}) })
}) }).then((response) => {
.then((response) => {
if (!response.ok) throw new Error('Bad status code from server.') if (!response.ok) throw new Error('Bad status code from server.')
return response.json() return response.json()
}) }).then((responseData) => {
.then((responseData) => {
if (!responseData.id) throw new Error('Bad response from server.') if (!responseData.id) throw new Error('Bad response from server.')
return responseData return responseData
}) })
} }
export default function registerPushNotifications (isEnabled, vapidPublicKey, token) { export function registerPushNotifications (isEnabled, vapidPublicKey, token, notificationVisibility) {
if (isPushSupported()) { if (isPushSupported()) {
registerServiceWorker() getOrCreateServiceWorker()
.then((registration) => subscribe(registration, isEnabled, vapidPublicKey)) .then((registration) => subscribePush(registration, isEnabled, vapidPublicKey))
.then((subscription) => sendSubscriptionToBackEnd(subscription, token)) .then((subscription) => sendSubscriptionToBackEnd(subscription, token, notificationVisibility))
.catch((e) => console.warn(`Failed to setup Web Push Notifications: ${e.message}`)) .catch((e) => console.warn(`Failed to setup Web Push Notifications: ${e.message}`))
} }
} }
export function unregisterPushNotifications (token) {
if (isPushSupported()) {
Promise.all([
deleteSubscriptionFromBackEnd(token),
getOrCreateServiceWorker()
.then((registration) => {
return unsubscribePush(registration).then((result) => [registration, result])
})
.then(([registration, unsubResult]) => {
if (!unsubResult) {
console.warn('Push subscription cancellation wasn\'t successful, killing SW anyway...')
}
return registration.unregister().then((result) => {
if (!result) {
console.warn('Failed to kill SW')
}
})
})
]).catch((e) => console.warn(`Failed to disable Web Push Notifications: ${e.message}`))
}
}

View file

@ -1,10 +1,10 @@
import { map } from 'lodash' import { map } from 'lodash'
import apiService from '../api/api.service.js' import apiService from '../api/api.service.js'
const postStatus = ({ store, status, spoilerText, visibility, sensitive, media = [], inReplyToStatusId = undefined }) => { 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}) return apiService.postStatus({credentials: store.state.users.currentUser.credentials, status, spoilerText, visibility, sensitive, mediaIds, inReplyToStatusId, contentType, noAttachmentLinks: store.state.instance.noAttachmentLinks})
.then((data) => data.json()) .then((data) => data.json())
.then((data) => { .then((data) => {
if (!data.error) { if (!data.error) {

View file

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

View file

@ -5,17 +5,19 @@
"logo": "/static/logo.svg", "logo": "/static/logo.svg",
"logoMask": true, "logoMask": true,
"logoMargin": ".1em", "logoMargin": ".1em",
"redirectRootNoLogin": "/~/main/all", "redirectRootNoLogin": "/main/all",
"redirectRootLogin": "/~/main/friends", "redirectRootLogin": "/main/friends",
"chatDisabled": false, "chatDisabled": false,
"showInstanceSpecificPanel": false, "showInstanceSpecificPanel": false,
"scopeOptionsEnabled": false, "scopeOptionsEnabled": false,
"formattingOptionsEnabled": false, "formattingOptionsEnabled": false,
"collapseMessageWithSubject": false, "collapseMessageWithSubject": false,
"scopeCopy": false, "scopeCopy": true,
"subjectLineBehavior": "noop", "subjectLineBehavior": "noop",
"alwaysShowSubjectInput": false, "alwaysShowSubjectInput": false,
"hidePostStats": false, "hidePostStats": false,
"hideUserStats": false, "hideUserStats": false,
"loginMethod": "password" "loginMethod": "password",
"webPushNotifications": false,
"noAttachmentLinks": false
} }

View file

@ -12,7 +12,7 @@ describe('routes', () => {
}) })
it('root path', () => { it('root path', () => {
router.push('/~/main/all') router.push('/main/all')
const matchedComponents = router.getMatchedComponents() const matchedComponents = router.getMatchedComponents()
@ -26,4 +26,12 @@ describe('routes', () => {
expect(matchedComponents[0].components.hasOwnProperty('UserCardContent')).to.eql(true) expect(matchedComponents[0].components.hasOwnProperty('UserCardContent')).to.eql(true)
}) })
it('user\'s profile at /users', () => {
router.push('/users/fake-user-name')
const matchedComponents = router.getMatchedComponents()
expect(matchedComponents[0].components.hasOwnProperty('UserCardContent')).to.eql(true)
})
}) })

View file

@ -2,16 +2,36 @@ import { mount, createLocalVue } from '@vue/test-utils'
import Vuex from 'vuex' import Vuex from 'vuex'
import UserProfile from 'src/components/user_profile/user_profile.vue' import UserProfile from 'src/components/user_profile/user_profile.vue'
import backendInteractorService from 'src/services/backend_interactor_service/backend_interactor_service.js' import backendInteractorService from 'src/services/backend_interactor_service/backend_interactor_service.js'
import { getters } from 'src/modules/users.js'
const localVue = createLocalVue() const localVue = createLocalVue()
localVue.use(Vuex) localVue.use(Vuex)
const mutations = { const mutations = {
clearTimeline: () => {} clearTimeline: () => {},
setError: () => {}
}
const testGetters = {
userByName: state => getters.userByName(state.users),
userById: state => getters.userById(state.users)
}
const localUser = {
id: 100,
is_local: true,
screen_name: 'testUser'
}
const extUser = {
id: 100,
is_local: false,
screen_name: 'testUser@test.instance'
} }
const externalProfileStore = new Vuex.Store({ const externalProfileStore = new Vuex.Store({
mutations, mutations,
getters: testGetters,
state: { state: {
api: { api: {
backendInteractor: backendInteractorService('') backendInteractor: backendInteractorService('')
@ -44,7 +64,7 @@ const externalProfileStore = new Vuex.Store({
followers: [], followers: [],
friends: [], friends: [],
viewing: 'statuses', viewing: 'statuses',
userId: 701, userId: 100,
flushMarker: 0 flushMarker: 0
} }
} }
@ -53,58 +73,15 @@ const externalProfileStore = new Vuex.Store({
currentUser: { currentUser: {
credentials: '' credentials: ''
}, },
usersObject: [ usersObject: [extUser],
{ users: [extUser]
background_image: null,
cover_photo: 'https://playvicious.social/system/accounts/headers/000/000/001/original/7dae4fc0e8330e83.jpg?1507329206',
created_at: 'Mon Dec 18 16:01:35 +0000 2017',
default_scope: 'public',
description: "Your favorite person's favorite person.",
description_html: "<p>Your favorite person's favorite person.</p>",
favourites_count: 0,
fields: [
{
name: '✌🏾',
value: '<a href="https://thetwelfth.house" rel="me nofollow noopener" target="_blank"><span class="invisible">https://</span><span class="">thetwelfth.house</span><span class="invisible"></span></a>'
},
{
name: '🚧',
value: '<a href="https://code.playvicio.us" rel="me nofollow noopener" target="_blank"><span class="invisible">https://</span><span class="">code.playvicio.us</span><span class="invisible"></span></a>'
},
{
name: '❤️',
value: '<a href="https://www.patreon.com/Are0h" rel="me nofollow noopener" target="_blank"><span class="invisible">https://www.</span><span class="">patreon.com/Are0h</span><span class="invisible"></span></a>'
}
],
followers_count: 2,
following: false,
follows_you: false,
friends_count: 0,
id: 701,
is_local: false,
locked: false,
name: 'Are0h',
name_html: 'Are0h',
no_rich_text: false,
profile_image_url: 'https://playvicious.social/system/accounts/avatars/000/000/001/original/33e9983bc2d96aeb.png?1520872572',
profile_image_url_https: 'https://playvicious.social/system/accounts/avatars/000/000/001/original/33e9983bc2d96aeb.png?1520872572',
profile_image_url_original: 'https://playvicious.social/system/accounts/avatars/000/000/001/original/33e9983bc2d96aeb.png?1520872572',
profile_image_url_profile_size: 'https://playvicious.social/system/accounts/avatars/000/000/001/original/33e9983bc2d96aeb.png?1520872572',
rights: {
delete_others_notice: false
},
screen_name: 'Are0h@playvicious.social',
statuses_count: 6727,
statusnet_blocking: false,
statusnet_profile_url: 'https://playvicious.social/users/Are0h'
}
]
} }
} }
}) })
const localProfileStore = new Vuex.Store({ const localProfileStore = new Vuex.Store({
mutations, mutations,
getters: testGetters,
state: { state: {
api: { api: {
backendInteractor: backendInteractorService('') backendInteractor: backendInteractorService('')
@ -137,7 +114,7 @@ const localProfileStore = new Vuex.Store({
followers: [], followers: [],
friends: [], friends: [],
viewing: 'statuses', viewing: 'statuses',
userId: 701, userId: 100,
flushMarker: 0 flushMarker: 0
} }
} }
@ -146,52 +123,8 @@ const localProfileStore = new Vuex.Store({
currentUser: { currentUser: {
credentials: '' credentials: ''
}, },
usersObject: [ usersObject: [localUser],
{ users: [localUser]
background_image: null,
cover_photo: 'https://playvicious.social/system/accounts/headers/000/000/001/original/7dae4fc0e8330e83.jpg?1507329206',
created_at: 'Mon Dec 18 16:01:35 +0000 2017',
default_scope: 'public',
description: "Your favorite person's favorite person.",
description_html: "<p>Your favorite person's favorite person.</p>",
favourites_count: 0,
fields: [
{
name: '✌🏾',
value: '<a href="https://thetwelfth.house" rel="me nofollow noopener" target="_blank"><span class="invisible">https://</span><span class="">thetwelfth.house</span><span class="invisible"></span></a>'
},
{
name: '🚧',
value: '<a href="https://code.playvicio.us" rel="me nofollow noopener" target="_blank"><span class="invisible">https://</span><span class="">code.playvicio.us</span><span class="invisible"></span></a>'
},
{
name: '❤️',
value: '<a href="https://www.patreon.com/Are0h" rel="me nofollow noopener" target="_blank"><span class="invisible">https://www.</span><span class="">patreon.com/Are0h</span><span class="invisible"></span></a>'
}
],
followers_count: 2,
following: false,
follows_you: false,
friends_count: 0,
id: 701,
is_local: false,
locked: false,
name: 'Are0h',
name_html: 'Are0h',
no_rich_text: false,
profile_image_url: 'https://playvicious.social/system/accounts/avatars/000/000/001/original/33e9983bc2d96aeb.png?1520872572',
profile_image_url_https: 'https://playvicious.social/system/accounts/avatars/000/000/001/original/33e9983bc2d96aeb.png?1520872572',
profile_image_url_original: 'https://playvicious.social/system/accounts/avatars/000/000/001/original/33e9983bc2d96aeb.png?1520872572',
profile_image_url_profile_size: 'https://playvicious.social/system/accounts/avatars/000/000/001/original/33e9983bc2d96aeb.png?1520872572',
rights: {
delete_others_notice: false
},
screen_name: 'Are0h',
statuses_count: 6727,
statusnet_blocking: false,
statusnet_profile_url: 'https://playvicious.social/users/Are0h'
}
]
} }
} }
}) })
@ -203,14 +136,14 @@ describe('UserProfile', () => {
store: externalProfileStore, store: externalProfileStore,
mocks: { mocks: {
$route: { $route: {
params: { id: 701 }, params: { id: 100 },
name: 'external-user-profile' name: 'external-user-profile'
}, },
$t: (msg) => msg $t: (msg) => msg
} }
}) })
expect(wrapper.find('.user-screen-name').text()).to.eql('@Are0h@playvicious.social') expect(wrapper.find('.user-screen-name').text()).to.eql('@testUser@test.instance')
}) })
it('renders local profile', () => { it('renders local profile', () => {
@ -219,13 +152,13 @@ describe('UserProfile', () => {
store: localProfileStore, store: localProfileStore,
mocks: { mocks: {
$route: { $route: {
params: { name: 'Are0h' }, params: { name: 'testUser' },
name: 'user-profile' name: 'user-profile'
}, },
$t: (msg) => msg $t: (msg) => msg
} }
}) })
expect(wrapper.find('.user-screen-name').text()).to.eql('@Are0h') expect(wrapper.find('.user-screen-name').text()).to.eql('@testUser')
}) })
}) })

View file

@ -1,8 +1,9 @@
import { cloneDeep } from 'lodash' import { cloneDeep } from 'lodash'
import { defaultState, mutations } from '../../../../src/modules/users.js' import { defaultState, mutations, getters } from '../../../../src/modules/users.js'
describe('The users module', () => { describe('The users module', () => {
describe('mutations', () => {
it('adds new users to the set, merging in new information for old users', () => { it('adds new users to the set, merging in new information for old users', () => {
const state = cloneDeep(defaultState) const state = cloneDeep(defaultState)
const user = { id: 1, name: 'Guy' } const user = { id: 1, name: 'Guy' }
@ -32,3 +33,30 @@ describe('The users module', () => {
expect(user.muted).to.eql(false) expect(user.muted).to.eql(false)
}) })
}) })
describe('getUserByName', () => {
it('returns user with matching screen_name', () => {
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', () => {
const state = {
users: [
{ screen_name: 'Guy', id: 1 }
]
}
const id = 1
const expected = { screen_name: 'Guy', id: 1 }
expect(getters.userById(state)(id)).to.eql(expected)
})
})
})

View file

@ -0,0 +1,88 @@
import * as NotificationUtils from 'src/services/notification_utils/notification_utils.js'
describe('NotificationUtils', () => {
describe('visibleNotificationsFromStore', () => {
it('should return sorted notifications with configured types', () => {
const store = {
state: {
statuses: {
notifications: {
data: [
{
action: { id: 1 },
type: 'like'
},
{
action: { id: 2 },
type: 'mention'
},
{
action: { id: 3 },
type: 'repeat'
}
]
}
},
config: {
notificationVisibility: {
likes: true,
repeats: true,
mentions: false
}
}
}
}
const expected = [
{
action: { id: 3 },
type: 'repeat'
},
{
action: { id: 1 },
type: 'like'
}
]
expect(NotificationUtils.visibleNotificationsFromStore(store)).to.eql(expected)
})
})
describe('unseenNotificationsFromStore', () => {
it('should return only notifications not marked as seen', () => {
const store = {
state: {
statuses: {
notifications: {
data: [
{
action: { id: 1 },
type: 'like',
seen: false
},
{
action: { id: 2 },
type: 'mention',
seen: true
}
]
}
},
config: {
notificationVisibility: {
likes: true,
repeats: true,
mentions: false
}
}
}
}
const expected = [
{
action: { id: 1 },
type: 'like',
seen: false
}
]
expect(NotificationUtils.unseenNotificationsFromStore(store)).to.eql(expected)
})
})
})

View file

@ -12,4 +12,10 @@ describe('generateProfileLink', () => {
name: 'external-user-profile', params: { id: 1 } name: 'external-user-profile', params: { id: 1 }
}) })
}) })
it('returns obj for restricted user', () => {
expect(generateProfileLink(1, 'lain', ['lain'])).to.eql({
name: 'external-user-profile', params: { id: 1 }
})
})
}) })