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

View file

@ -6,6 +6,8 @@ import InstanceSpecificPanel from './components/instance_specific_panel/instance
import FeaturesPanel from './components/features_panel/features_panel.vue'
import WhoToFollowPanel from './components/who_to_follow_panel/who_to_follow_panel.vue'
import ChatPanel from './components/chat_panel/chat_panel.vue'
import SideDrawer from './components/side_drawer/side_drawer.vue'
import { unseenNotificationsFromStore } from './services/notification_utils/notification_utils'
export default {
name: 'app',
@ -17,7 +19,8 @@ export default {
InstanceSpecificPanel,
FeaturesPanel,
WhoToFollowPanel,
ChatPanel
ChatPanel,
SideDrawer
},
data: () => ({
mobileActivePanel: 'timeline',
@ -70,12 +73,15 @@ export default {
sitename () { return this.$store.state.instance.name },
chat () { return this.$store.state.chat.channel.state === 'joined' },
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: {
activatePanel (panelName) {
this.mobileActivePanel = panelName
},
scrollToTop () {
window.scrollTo(0, 0)
},
@ -85,6 +91,9 @@ export default {
},
onFinderToggled (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 {
transition: opacity .2s
}
@ -524,9 +542,6 @@ nav {
.back-button {
display: none;
}
.site-name {
padding-left: 20px;
}
}
.sidebar-bounds {
@ -665,4 +680,9 @@ nav {
max-width: 4em;
}
}
.menu-button {
display: block;
margin-right: 0.8em;
}
}

View file

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

View file

@ -72,6 +72,7 @@ const afterStoreSetup = ({ store, i18n }) => {
var scopeCopy = (config.scopeCopy)
var subjectLineBehavior = (config.subjectLineBehavior)
var alwaysShowSubjectInput = (config.alwaysShowSubjectInput)
var noAttachmentLinks = (config.noAttachmentLinks)
store.dispatch('setInstanceOption', { name: 'theme', value: theme })
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: 'subjectLineBehavior', value: subjectLineBehavior })
store.dispatch('setInstanceOption', { name: 'alwaysShowSubjectInput', value: alwaysShowSubjectInput })
store.dispatch('setInstanceOption', { name: 'noAttachmentLinks', value: noAttachmentLinks })
if (chatDisabled) {
store.dispatch('disableChat')
}
@ -165,6 +168,8 @@ const afterStoreSetup = ({ store, i18n }) => {
store.dispatch('setInstanceOption', { name: 'chatAvailable', value: features.includes('chat') })
store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') })
store.dispatch('setInstanceOption', { name: 'restrictedNicknames', value: metadata.restrictedNicknames })
const suggestions = metadata.suggestions
store.dispatch('setInstanceOption', { name: 'suggestionsEnabled', value: suggestions.enabled })
store.dispatch('setInstanceOption', { name: 'suggestionsWeb', value: suggestions.web })

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 OAuthCallback from 'components/oauth_callback/oauth_callback.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) => {
return [
@ -20,48 +24,28 @@ export default (store) => {
redirect: _to => {
return (store.state.users.currentUser
? 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-timeline', path: '/~/main/public', component: PublicTimeline },
{ name: 'friends', path: '/~/main/friends', component: FriendsTimeline },
// Beginning of temporary redirects
{ path: '/main/:route',
redirect: to => {
const { params } = to
const route = params.route ? params.route : 'all'
return { path: `/~/main/${route}` }
}
},
{ path: '/tag/:tag',
redirect: to => {
const { params } = to
return { path: `/~/tag/${params.tag}` }
}
},
{ path: '/notice/:id',
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 }) }
{ name: 'public-external-timeline', path: '/main/all', component: PublicAndExternalTimeline },
{ name: 'public-timeline', path: '/main/public', component: PublicTimeline },
{ name: 'friends', path: '/main/friends', component: FriendsTimeline },
{ name: 'tag-timeline', path: '/tag/:tag', component: TagTimeline },
{ name: 'conversation', path: '/notice/:id', component: ConversationPage, meta: { dontScroll: true } },
{ name: 'external-user-profile', path: '/users/:id', component: UserProfile },
{ name: 'mentions', path: '/users/:username/mentions', component: Mentions },
{ name: 'dms', path: '/users/: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: 'notifications', path: '/:username/notifications', component: Notifications },
{ name: 'new-status', path: '/:username/new-status', component: UserPanel },
{ name: 'login', path: '/login', component: LoginForm },
{ 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 }) },
{ name: 'user-profile', path: '/(users/)?:name', component: UserProfile }
]
}

View file

@ -1,6 +1,7 @@
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
const chatPanel = {
props: [ 'floating' ],
data () {
return {
currentMessage: '',
@ -22,7 +23,7 @@ const chatPanel = {
this.collapsed = !this.collapsed
},
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>
<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-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">
{{$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 class="chat-window" v-chat-scroll>
@ -52,6 +52,7 @@
right: 0px;
bottom: 0px;
z-index: 1000;
max-width: 25em;
}
.chat-heading {
@ -63,10 +64,13 @@
}
.chat-window {
width: 345px;
max-height: 40vh;
overflow-y: auto;
overflow-x: hidden;
max-height: 20em;
}
.chat-window-container {
height: 100%;
}
.chat-message {

View file

@ -32,7 +32,7 @@ const LoginForm = {
.then((result) => {
this.$store.commit('setToken', 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 = {
props: [ 'activatePanel' ],
computed: {
currentUser () {
return this.$store.state.users.currentUser

View file

@ -3,32 +3,32 @@
<div class="panel panel-default">
<ul>
<li v-if='currentUser'>
<router-link @click.native="activatePanel('timeline')" :to="{ name: 'friends' }">
<router-link :to="{ name: 'friends' }">
{{ $t("nav.timeline") }}
</router-link>
</li>
<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") }}
</router-link>
</li>
<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") }}
</router-link>
</li>
<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") }}
</router-link>
</li>
<li>
<router-link @click.native="activatePanel('timeline')" :to="{ name: 'public-timeline' }">
<router-link :to="{ name: 'public-timeline' }">
{{ $t("nav.public_tl") }}
</router-link>
</li>
<li>
<router-link @click.native="activatePanel('timeline')" :to="{ name: 'public-external-timeline' }">
<router-link :to="{ name: 'public-external-timeline' }">
{{ $t("nav.twkn") }}
</router-link>
</li>

View file

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

View file

@ -1,5 +1,5 @@
<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>
<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"/>
@ -25,15 +25,15 @@
<small>{{$t('notifications.followed_you')}}</small>
</span>
</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>
<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}}
</router-link>
</div>
<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>
</div>
</div>

View file

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

View file

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

View file

@ -11,7 +11,7 @@ const oac = {
}).then((result) => {
this.$store.commit('setToken', 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')
let el = this.$el.querySelector('textarea')
el.style.height = 'auto'
el.style.height = undefined
this.error = null
} else {

View file

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

View file

@ -76,15 +76,16 @@
</div>
<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'">
<img v-bind:src="captcha.url" v-on:click="setCaptcha">
<sub>Click the image to get a new captcha</sub>
<label class='form--label' for='captcha-label'>CAPTCHA</label>
<sub>{{$t('registration.new_captcha')}}</sub>
<input :disabled="isPending"
v-model='captcha.solution'
class='form-control' id='captcha-answer' type='text'>
class='form-control' id='captcha-answer' type='text' autocomplete="off">
</template>
</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',
'noReplyLinks',
'noHeading',
'inlineExpanded',
'activatePanel'
'inlineExpanded'
],
data () {
return {
@ -291,7 +290,7 @@ const Status = {
this.showPreview = false
},
userProfileLink (id, name) {
return generateProfileLink(id, name)
return generateProfileLink(id, name, this.$store.state.instance.restrictedNicknames)
}
},
watch: {

View file

@ -3,7 +3,7 @@
<template v-if="muted && !noReplyLinks">
<div class="media status container muted">
<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}}
</router-link>
</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-else>{{status.user.name}}</h4>
<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}}
</router-link>
<span v-if="status.in_reply_to_screen_name" class="faint reply-info">
<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}}
</router-link>
</span>
@ -60,7 +60,7 @@
</h4>
</div>
<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>
</router-link>
<div class="button-icon visibility-icon" v-if="status.visibility">
@ -79,7 +79,7 @@
</div>
<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>
<i class="icon-spin4 animate-spin"></i>
</div>

View file

@ -33,7 +33,7 @@ const UserCard = {
this.$store.dispatch('removeFollowRequest', this.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'
export default {
props: [ 'user', 'switcher', 'selected', 'hideBio', 'activatePanel' ],
props: [ 'user', 'switcher', 'selected', 'hideBio' ],
data () {
return {
followRequestInProgress: false,
@ -180,7 +180,7 @@ export default {
}
},
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 class="panel-heading text-center">
<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'>
<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"/>
</router-link>
<div class="name-and-screen-name">
<div :title="user.name" class='user-name' v-if="user.name_html" v-html="user.name_html"></div>
<div :title="user.name" class='user-name' v-else>{{user.name}}</div>
<router-link @click.native="activatePanel && activatePanel('timeline')" class='user-screen-name' :to="userProfileLink(user)">
<span>@{{user.screen_name}}</span><span v-if="user.locked"><i class="icon icon-lock"></i></span>
<span v-if="!hideUserStatsLocal" class="dailyAvg">{{dailyAvg}} {{ $t('user_card.per_day') }}</span>
<div class="top-line">
<div :title="user.name" class='user-name' v-if="user.name_html" v-html="user.name_html"></div>
<div :title="user.name" class='user-name' v-else>{{user.name}}</div>
<router-link :to="{ name: 'user-settings' }" v-if="!isOtherUser">
<i class="button-icon icon-cog usersettings" :title="$t('tool_tip.user_settings')"></i>
</router-link>
<a :href="user.statusnet_profile_url" target="_blank" v-if="isOtherUser">
<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>
</div>
</div>
@ -25,7 +28,7 @@
<div v-if="user.follows_you && loggedIn && isOtherUser" class="following">
{{ $t('user_card.follows_you') }}
</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 -->
<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"/>
@ -139,7 +142,7 @@
border-bottom-right-radius: 0;
.panel-heading {
padding: 0.6em 0em;
padding: .6em 0;
text-align: center;
box-shadow: none;
}
@ -158,10 +161,10 @@
.user-info {
color: $fallback--lightText;
color: var(--lightText, $fallback--lightText);
padding: 0 16px;
padding: 0 26px;
.container {
padding: 16px 10px 6px 10px;
padding: 16px 0 6px;
display: flex;
max-height: 56px;
@ -218,11 +221,15 @@
vertical-align: middle;
object-fit: contain
}
.top-line {
display: flex;
}
}
.user-name{
text-overflow: ellipsis;
overflow: hidden;
flex: 1 0 auto;
}
.user-screen-name {
@ -232,27 +239,73 @@
font-weight: light;
font-size: 15px;
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 {
margin-bottom: .4em;
margin-bottom: .15em;
display: flex;
align-items: baseline;
font-size: 14px;
line-height: 22px;
flex-wrap: wrap;
.following {
font-size: 14px;
flex: 0 0 100%;
flex: 1 0 auto;
margin: 0;
padding-left: 16px;
margin-bottom: .25em;
text-align: left;
float: left;
}
.floater {
margin: 0;
}
&::after {
display: block;
content: '';
clear: both;
.highlighter {
flex: 0 1 auto;
display: flex;
flex-wrap: wrap;
margin-right: -.5em;
align-self: start;
.userHighlightCl {
padding: 2px 10px;
flex: 1 0 auto;
}
.userHighlightSel,
.userHighlightSel.select {
padding-top: 0;
padding-bottom: 0;
flex: 1 0 auto;
}
.userHighlightSel.select i {
line-height: 22px;
}
.userHighlightText {
width: 70px;
flex: 1 0 auto;
}
.userHighlightCl,
.userHighlightText,
.userHighlightSel,
.userHighlightSel.select {
height: 22px;
vertical-align: top;
margin-right: .5em;
margin-bottom: .25em;
}
}
}
.user-interactions {
@ -260,8 +313,13 @@
flex-flow: row wrap;
justify-content: space-between;
margin-right: -.75em;
div {
flex: 1;
flex: 1 0 0;
margin-right: .75em;
margin-bottom: .6em;
white-space: nowrap;
}
.mute {
@ -280,8 +338,9 @@
}
button {
width: 92%;
width: 100%;
height: 100%;
margin: 0;
}
.remote-button {
@ -304,10 +363,11 @@
justify-content: space-between;
color: $fallback--lightText;
color: var(--lightText, $fallback--lightText);
flex-wrap: wrap;
}
.user-count {
flex: 1;
flex: 1 0 auto;
padding: .5em 0 .5em 0;
margin: 0 .5em;
@ -327,32 +387,5 @@
color: #CCC;
}
.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>

View file

@ -1,14 +1,16 @@
<template>
<div class="user-finder-container">
<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>
<template v-else>
<input class="user-finder-input" @keyup.enter="findUser(username)" v-model="username" :placeholder="$t('finder.find_user')" id="user-finder-input" type="text"/>
<button class="btn search-button" @click="findUser(username)">
<i class="icon-search"/>
</button>
<i class="button-icon icon-cancel user-finder-icon" @click.prevent.stop="toggleHidden"/>
</template>
<div>
<div class="user-finder-container">
<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>
<template v-else>
<input class="user-finder-input" @keyup.enter="findUser(username)" v-model="username" :placeholder="$t('finder.find_user')" id="user-finder-input" type="text"/>
<button class="btn search-button" @click="findUser(username)">
<i class="icon-search"/>
</button>
<i class="button-icon icon-cancel user-finder-icon" @click.prevent.stop="toggleHidden"/>
</template>
</div>
</div>
</template>

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'
const UserPanel = {
props: [ 'activatePanel' ],
computed: {
user () { return this.$store.state.users.currentUser }
},

View file

@ -1,7 +1,7 @@
<template>
<div class="user-panel">
<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">
<post-status-form v-if='user'></post-status-form>
</div>

View file

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

View file

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

View file

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

View file

@ -3,6 +3,12 @@
<div class="panel-heading">
{{$t('nav.user_search')}}
</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">
<user-card v-for="user in users" :key="user.id" :user="user" :showFollows="true"></user-card>
</div>
@ -10,3 +16,15 @@
</template>
<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) => {
if (res.status === 'success') {
this.$store.dispatch('logout')
this.$router.push('/~/main/all')
this.$router.push({name: 'root'})
} else {
this.deleteAccountError = res.error
}

View file

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

View file

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

View file

@ -6,7 +6,7 @@
"chat": "Chat",
"gopher": "Gopher",
"media_proxy": "Media Proxy",
"scope_options": "Scope options",
"scope_options": "Reichweitenoptionen",
"text_limit": "Textlimit",
"title": "Features",
"who_to_follow": "Who to follow"
@ -29,12 +29,16 @@
"username": "Benutzername"
},
"nav": {
"back": "Zurück",
"chat": "Lokaler Chat",
"friend_requests": "Followanfragen",
"mentions": "Erwähnungen",
"public_tl": "Lokale Zeitleiste",
"dms": "Direktnachrichten",
"public_tl": "Öffentliche Zeitleiste",
"timeline": "Zeitleiste",
"twkn": "Das gesamte bekannte Netzwerk"
"twkn": "Das gesamte bekannte Netzwerk",
"user_search": "Benutzersuche",
"preferences": "Voreinstellungen"
},
"notifications": {
"broken_favorite": "Unbekannte Nachricht, suche danach...",
@ -46,6 +50,7 @@
"repeated_you": "wiederholte deine Nachricht"
},
"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_link": "gesperrt",
"attachments_sensitive": "Anhänge als heikel markieren",
@ -69,7 +74,17 @@
"fullname": "Angezeigter Name",
"password_confirm": "Passwort bestätigen",
"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": {
"attachmentRadius": "Anhänge",
@ -89,6 +104,7 @@
"change_password_error": "Es gab ein Problem bei der Änderung des Passworts.",
"changed_password": "Passwort erfolgreich geändert!",
"collapse_subject": "Beiträge mit Betreff einklappen",
"composing": "Verfassen",
"confirm_new_password": "Neues Passwort bestätigen",
"current_avatar": "Dein derzeitiger Avatar",
"current_password": "Aktuelles Passwort",
@ -112,12 +128,17 @@
"general": "Allgemein",
"hide_attachments_in_convo": "Anhänge in Unterhaltungen 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_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_theme": "Farbschema laden",
"inputRadius": "Eingabefelder",
"checkboxRadius": "Auswahlfelder",
"instance_default": "(Standard: {value})",
"instance_default_simple": "(Standard)",
"interface": "Oberfläche",
"interfaceLanguage": "Sprache der Oberfläche",
"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",
@ -134,6 +155,7 @@
"notification_visibility_mentions": "Erwähnungen",
"notification_visibility_repeats": "Wiederholungen",
"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",
"panelRadius": "Panel",
"pause_on_unfocused": "Streaming pausieren, wenn das Tab nicht fokussiert ist",
@ -150,20 +172,139 @@
"saving_err": "Fehler beim Speichern der Einstellungen",
"saving_ok": "Einstellungen gespeichert",
"security_tab": "Sicherheit",
"scope_copy": "Reichweite beim Antworten übernehmen (Direktnachrichten werden immer kopiert)",
"set_new_avatar": "Setze einen neuen Avatar",
"set_new_profile_background": "Setze einen neuen Hintergrund für dein Profil",
"set_new_profile_banner": "Setze einen neuen Banner für dein Profil",
"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",
"streaming": "Aktiviere automatisches Laden (Streaming) von neuen Beiträgen",
"text": "Text",
"theme": "Farbschema",
"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",
"user_settings": "Benutzereinstellungen",
"values": {
"false": "nein",
"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": {
@ -182,10 +323,15 @@
"blocked": "Blockiert!",
"deny": "Ablehnen",
"follow": "Folgen",
"follow_sent": "Anfrage gesendet!",
"follow_progress": "Anfragen…",
"follow_again": "Anfrage erneut senden?",
"follow_unfollow": "Folgen beenden",
"followees": "Folgt",
"followers": "Followers",
"following": "Folgst du!",
"follows_you": "Folgt dir!",
"its_you": "Das bist du!",
"mute": "Stummschalten",
"muted": "Stummgeschaltet",
"per_day": "pro Tag",
@ -198,5 +344,26 @@
"who_to_follow": {
"more": "Mehr",
"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"
},
"post_status": {
"new_status": "Post new status",
"account_not_locked_warning": "Your account is not {0}. Anyone can follow you to view your follower-only posts.",
"account_not_locked_warning_link": "locked",
"attachments_sensitive": "Mark attachments as sensitive",
@ -74,6 +75,8 @@
"password_confirm": "Password confirmation",
"registration": "Registration",
"token": "Invite token",
"captcha": "CAPTCHA",
"new_captcha": "Click the image to get a new captcha",
"validations": {
"username_required": "cannot be left blank",
"fullname_required": "cannot be left blank",

View file

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

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 createPersistedState from './lib/persisted_state.js'
import pushNotifications from './lib/push_notifications_plugin.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) => {
const store = new Vuex.Store({
modules: {
@ -88,7 +64,7 @@ createPersistedState(persistedStateOptions).then((persistedState) => {
chat: chatModule,
oauth: oauthModule
},
plugins: [persistedState, registerPushNotifications],
plugins: [persistedState, pushNotifications],
strict: false // Socket modifies itself, let's ignore this for now.
// strict: process.env.NODE_ENV !== 'production'
})

View file

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

View file

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

View file

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

View file

@ -1,7 +1,7 @@
import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
import { compact, map, each, merge } from 'lodash'
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 { humanizeErrors } from './errors'
@ -66,6 +66,9 @@ export const mutations = {
setUserForStatus (state, status) {
status.user = state.usersObject[status.user.id]
},
setUserForNotification (state, notification) {
notification.action.user = state.usersObject[notification.action.user.id]
},
setColor (state, { user: { id }, highlighted }) {
const user = state.usersObject[id]
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 = {
loggingIn: false,
lastLoginName: false,
@ -96,6 +106,7 @@ export const defaultState = {
const users = {
state: defaultState,
mutations,
getters,
actions: {
fetchUser (store, id) {
store.rootState.api.backendInteractor.fetchUser({ id })
@ -113,8 +124,14 @@ const users = {
const token = store.state.currentUser.credentials
const vapidPublicKey = store.rootState.instance.vapidPublicKey
const isEnabled = store.rootState.config.webPushNotifications
const notificationVisibility = store.rootState.config.notificationVisibility
registerPushNotifications(isEnabled, vapidPublicKey, token)
registerPushNotifications(isEnabled, vapidPublicKey, token, notificationVisibility)
},
unregisterPushNotifications (store) {
const token = store.state.currentUser.credentials
unregisterPushNotifications(token)
},
addNewStatuses (store, { statuses }) {
const users = map(statuses, 'user')
@ -131,6 +148,21 @@ const users = {
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) {
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 form = new FormData()
form.append('status', status)
form.append('source', 'Pleroma FE')
if (noAttachmentLinks) form.append('no_attachment_links', noAttachmentLinks)
if (spoilerText) form.append('spoiler_text', spoilerText)
if (visibility) form.append('visibility', visibility)
if (sensitive) form.append('sensitive', sensitive)

View file

@ -5,7 +5,7 @@ const getOrCreateApp = ({oauth, instance}) => {
const form = new window.FormData()
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')
return window.fetch(url, {
@ -64,7 +64,7 @@ const getToken = ({app, instance, code}) => {
form.append('client_secret', app.client_secret)
form.append('grant_type', 'authorization_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, {
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
}
function registerServiceWorker () {
function getOrCreateServiceWorker () {
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 (!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)
}
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/', {
method: 'POST',
headers: {
@ -41,29 +62,49 @@ function sendSubscriptionToBackEnd (subscription, token) {
subscription,
data: {
alerts: {
follow: true,
favourite: true,
mention: true,
reblog: true
follow: notificationVisibility.follows,
favourite: notificationVisibility.likes,
mention: notificationVisibility.mentions,
reblog: notificationVisibility.repeats
}
}
})
}).then((response) => {
if (!response.ok) throw new Error('Bad status code from server.')
return response.json()
}).then((responseData) => {
if (!responseData.id) throw new Error('Bad response from server.')
return responseData
})
.then((response) => {
if (!response.ok) throw new Error('Bad status code from server.')
return response.json()
})
.then((responseData) => {
if (!responseData.id) throw new Error('Bad response from server.')
return responseData
})
}
export default function registerPushNotifications (isEnabled, vapidPublicKey, token) {
export function registerPushNotifications (isEnabled, vapidPublicKey, token, notificationVisibility) {
if (isPushSupported()) {
registerServiceWorker()
.then((registration) => subscribe(registration, isEnabled, vapidPublicKey))
.then((subscription) => sendSubscriptionToBackEnd(subscription, token))
getOrCreateServiceWorker()
.then((registration) => subscribePush(registration, isEnabled, vapidPublicKey))
.then((subscription) => sendSubscriptionToBackEnd(subscription, token, notificationVisibility))
.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 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')
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) => {
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 {
name: (isExternal(screenName) ? 'external-user-profile' : 'user-profile'),
params: (isExternal(screenName) ? { id } : { name: screenName })
name: (complicated ? 'external-user-profile' : 'user-profile'),
params: (complicated ? { id } : { name: screenName })
}
}

View file

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

View file

@ -12,7 +12,7 @@ describe('routes', () => {
})
it('root path', () => {
router.push('/~/main/all')
router.push('/main/all')
const matchedComponents = router.getMatchedComponents()
@ -26,4 +26,12 @@ describe('routes', () => {
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 UserProfile from 'src/components/user_profile/user_profile.vue'
import backendInteractorService from 'src/services/backend_interactor_service/backend_interactor_service.js'
import { getters } from 'src/modules/users.js'
const localVue = createLocalVue()
localVue.use(Vuex)
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({
mutations,
getters: testGetters,
state: {
api: {
backendInteractor: backendInteractorService('')
@ -44,7 +64,7 @@ const externalProfileStore = new Vuex.Store({
followers: [],
friends: [],
viewing: 'statuses',
userId: 701,
userId: 100,
flushMarker: 0
}
}
@ -53,58 +73,15 @@ const externalProfileStore = new Vuex.Store({
currentUser: {
credentials: ''
},
usersObject: [
{
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'
}
]
usersObject: [extUser],
users: [extUser]
}
}
})
const localProfileStore = new Vuex.Store({
mutations,
getters: testGetters,
state: {
api: {
backendInteractor: backendInteractorService('')
@ -137,7 +114,7 @@ const localProfileStore = new Vuex.Store({
followers: [],
friends: [],
viewing: 'statuses',
userId: 701,
userId: 100,
flushMarker: 0
}
}
@ -146,52 +123,8 @@ const localProfileStore = new Vuex.Store({
currentUser: {
credentials: ''
},
usersObject: [
{
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'
}
]
usersObject: [localUser],
users: [localUser]
}
}
})
@ -203,14 +136,14 @@ describe('UserProfile', () => {
store: externalProfileStore,
mocks: {
$route: {
params: { id: 701 },
params: { id: 100 },
name: 'external-user-profile'
},
$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', () => {
@ -219,13 +152,13 @@ describe('UserProfile', () => {
store: localProfileStore,
mocks: {
$route: {
params: { name: 'Are0h' },
params: { name: 'testUser' },
name: 'user-profile'
},
$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,34 +1,62 @@
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', () => {
it('adds new users to the set, merging in new information for old users', () => {
const state = cloneDeep(defaultState)
const user = { id: 1, name: 'Guy' }
const modUser = { id: 1, name: 'Dude' }
describe('mutations', () => {
it('adds new users to the set, merging in new information for old users', () => {
const state = cloneDeep(defaultState)
const user = { id: 1, name: 'Guy' }
const modUser = { id: 1, name: 'Dude' }
mutations.addNewUsers(state, [user])
expect(state.users).to.have.length(1)
expect(state.users).to.eql([user])
mutations.addNewUsers(state, [user])
expect(state.users).to.have.length(1)
expect(state.users).to.eql([user])
mutations.addNewUsers(state, [modUser])
expect(state.users).to.have.length(1)
expect(state.users).to.eql([user])
expect(state.users[0].name).to.eql('Dude')
mutations.addNewUsers(state, [modUser])
expect(state.users).to.have.length(1)
expect(state.users).to.eql([user])
expect(state.users[0].name).to.eql('Dude')
})
it('sets a mute bit on users', () => {
const state = cloneDeep(defaultState)
const user = { id: 1, name: 'Guy' }
mutations.addNewUsers(state, [user])
mutations.setMuted(state, {user, muted: true})
expect(user.muted).to.eql(true)
mutations.setMuted(state, {user, muted: false})
expect(user.muted).to.eql(false)
})
})
it('sets a mute bit on users', () => {
const state = cloneDeep(defaultState)
const user = { id: 1, name: 'Guy' }
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)
})
})
mutations.addNewUsers(state, [user])
mutations.setMuted(state, {user, muted: true})
expect(user.muted).to.eql(true)
mutations.setMuted(state, {user, muted: false})
expect(user.muted).to.eql(false)
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 }
})
})
it('returns obj for restricted user', () => {
expect(generateProfileLink(1, 'lain', ['lain'])).to.eql({
name: 'external-user-profile', params: { id: 1 }
})
})
})