Merge branch 'navigation-update' into shigusegubu-vue3
* navigation-update: lint limit amount of pins on desktop to 6 (for now) band-aid to prevent misclics on logout fixes, clear cache on logout let mobile users customize top bar as well fixes + fixes for anon users navigation refactored, used in mobile nav as well show pinned lists between timelines and rest you can now pin lists it works more or less well now ability to pin items in navigation menu, initial draft version add a todo for future server side storage support for collections + fixes update link in update notification to be a better one fixes more prefs storage work + move dontShowUpdateNotifs to prefs initial prefs storage work
This commit is contained in:
commit
d632f3ab76
35 changed files with 914 additions and 278 deletions
18
src/App.scss
18
src/App.scss
|
@ -117,8 +117,15 @@ h4 {
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
.iconLetter {
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
font-weight: 1000;
|
||||
}
|
||||
|
||||
i[class*=icon-],
|
||||
.svg-inline--fa {
|
||||
.svg-inline--fa,
|
||||
.iconLetter {
|
||||
color: $fallback--icon;
|
||||
color: var(--icon, $fallback--icon);
|
||||
}
|
||||
|
@ -746,16 +753,21 @@ option {
|
|||
}
|
||||
|
||||
.fa-scale-110 {
|
||||
&.svg-inline--fa {
|
||||
&.svg-inline--fa,
|
||||
&.iconLetter {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
}
|
||||
|
||||
.fa-old-padding {
|
||||
&.svg-inline--fa {
|
||||
&.svg-inline--fa,
|
||||
&.iconLetter {
|
||||
padding: 0 0.3em;
|
||||
}
|
||||
}
|
||||
.veryfaint {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
.login-hint {
|
||||
text-align: center;
|
||||
|
|
|
@ -23,6 +23,7 @@ import RemoteUserResolver from 'components/remote_user_resolver/remote_user_reso
|
|||
import Lists from 'components/lists/lists.vue'
|
||||
import ListsTimeline from 'components/lists_timeline/lists_timeline.vue'
|
||||
import ListsEdit from 'components/lists_edit/lists_edit.vue'
|
||||
import NavPanel from 'src/components/nav_panel/nav_panel.vue'
|
||||
|
||||
export default (store) => {
|
||||
const validateAuthenticatedRoute = (to, from, next) => {
|
||||
|
@ -78,7 +79,8 @@ export default (store) => {
|
|||
{ name: 'user-profile', path: '/:_(users)?/:name', component: UserProfile },
|
||||
{ name: 'lists', path: '/lists', component: Lists },
|
||||
{ name: 'lists-timeline', path: '/lists/:id', component: ListsTimeline },
|
||||
{ name: 'lists-edit', path: '/lists/:id/edit', component: ListsEdit }
|
||||
{ name: 'lists-edit', path: '/lists/:id/edit', component: ListsEdit },
|
||||
{ name: 'edit-navigation', path: '/nav-edit', component: NavPanel, props: () => ({ forceExpand: true }), beforeEnter: validateAuthenticatedRoute }
|
||||
]
|
||||
|
||||
if (store.state.instance.pleromaChatMessagesAvailable) {
|
||||
|
|
|
@ -117,4 +117,8 @@
|
|||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.spacer {
|
||||
width: 1em;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -61,6 +61,7 @@
|
|||
:title="$t('nav.administration')"
|
||||
/>
|
||||
</a>
|
||||
<span class="spacer" />
|
||||
<button
|
||||
v-if="currentUser"
|
||||
class="button-unstyled nav-icon"
|
||||
|
|
|
@ -1,28 +1,26 @@
|
|||
import { mapState } from 'vuex'
|
||||
import { library } from '@fortawesome/fontawesome-svg-core'
|
||||
import {
|
||||
faUsers,
|
||||
faGlobe,
|
||||
faBookmark,
|
||||
faEnvelope,
|
||||
faHome
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import NavigationEntry from 'src/components/navigation/navigation_entry.vue'
|
||||
|
||||
library.add(
|
||||
faUsers,
|
||||
faGlobe,
|
||||
faBookmark,
|
||||
faEnvelope,
|
||||
faHome
|
||||
)
|
||||
export const getListEntries = state => state.lists.allLists.map(list => ({
|
||||
name: 'list-' + list.id,
|
||||
routeObject: { name: 'lists-timeline', params: { id: list.id } },
|
||||
labelRaw: list.title,
|
||||
iconLetter: list.title[0]
|
||||
}))
|
||||
|
||||
const ListsMenuContent = {
|
||||
export const ListsMenuContent = {
|
||||
props: [
|
||||
'showPin'
|
||||
],
|
||||
created () {
|
||||
this.$store.dispatch('startFetchingLists')
|
||||
},
|
||||
components: {
|
||||
NavigationEntry
|
||||
},
|
||||
computed: {
|
||||
...mapState({
|
||||
lists: state => state.lists.allLists,
|
||||
lists: getListEntries,
|
||||
currentUser: state => state.users.currentUser,
|
||||
privateMode: state => state.instance.private,
|
||||
federating: state => state.instance.federating
|
||||
|
|
|
@ -1,16 +1,11 @@
|
|||
<template>
|
||||
<ul>
|
||||
<li
|
||||
v-for="list in lists.slice().reverse()"
|
||||
:key="list.id"
|
||||
>
|
||||
<router-link
|
||||
class="menu-item"
|
||||
:to="{ name: 'lists-timeline', params: { id: list.id } }"
|
||||
>
|
||||
{{ list.title }}
|
||||
</router-link>
|
||||
</li>
|
||||
<NavigationEntry
|
||||
v-for="item in lists"
|
||||
:key="item.name"
|
||||
:show-pin="showPin"
|
||||
:item="item"
|
||||
/>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@ import SideDrawer from '../side_drawer/side_drawer.vue'
|
|||
import Notifications from '../notifications/notifications.vue'
|
||||
import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils'
|
||||
import GestureService from '../../services/gesture_service/gesture_service'
|
||||
import NavigationPins from 'src/components/navigation/navigation_pins.vue'
|
||||
import { mapGetters } from 'vuex'
|
||||
import { library } from '@fortawesome/fontawesome-svg-core'
|
||||
import {
|
||||
|
@ -19,7 +20,8 @@ library.add(
|
|||
const MobileNav = {
|
||||
components: {
|
||||
SideDrawer,
|
||||
Notifications
|
||||
Notifications,
|
||||
NavigationPins
|
||||
},
|
||||
data: () => ({
|
||||
notificationsCloseGesture: undefined,
|
||||
|
@ -47,7 +49,10 @@ const MobileNav = {
|
|||
isChat () {
|
||||
return this.$route.name === 'chat'
|
||||
},
|
||||
...mapGetters(['unreadChatCount'])
|
||||
...mapGetters(['unreadChatCount']),
|
||||
chatsPinned () {
|
||||
return new Set(this.$store.state.serverSideStorage.prefsStorage.collections.pinnedNavItems).has('chats')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleMobileSidebar () {
|
||||
|
|
|
@ -17,20 +17,12 @@
|
|||
icon="bars"
|
||||
/>
|
||||
<div
|
||||
v-if="unreadChatCount"
|
||||
v-if="unreadChatCount && !chatsPinned"
|
||||
class="alert-dot"
|
||||
/>
|
||||
</button>
|
||||
<router-link
|
||||
v-if="!hideSitename"
|
||||
class="site-name"
|
||||
:to="{ name: 'root' }"
|
||||
active-class="home"
|
||||
>
|
||||
{{ sitename }}
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="item right">
|
||||
<NavigationPins class="pins" />
|
||||
</div> <div class="item right">
|
||||
<button
|
||||
v-if="currentUser"
|
||||
class="button-unstyled mobile-nav-button"
|
||||
|
@ -94,6 +86,7 @@
|
|||
grid-template-columns: 2fr auto;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
a {
|
||||
color: var(--topBarLink, $fallback--link);
|
||||
}
|
||||
|
@ -178,13 +171,20 @@
|
|||
}
|
||||
}
|
||||
|
||||
.pins {
|
||||
flex: 1;
|
||||
|
||||
.pinned-item {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-notifications {
|
||||
margin-top: 50px;
|
||||
width: 100vw;
|
||||
height: calc(100vh - var(--navbar-height));
|
||||
overflow-x: hidden;
|
||||
overflow-y: scroll;
|
||||
|
||||
color: $fallback--text;
|
||||
color: var(--text, $fallback--text);
|
||||
background-color: $fallback--bg;
|
||||
|
@ -194,14 +194,17 @@
|
|||
padding: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
|
||||
.panel {
|
||||
border-radius: 0;
|
||||
margin: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
.panel:after {
|
||||
|
||||
.panel::after {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.panel .panel-heading {
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
import TimelineMenuContent from '../timeline_menu/timeline_menu_content.vue'
|
||||
import ListsMenuContent from '../lists_menu/lists_menu_content.vue'
|
||||
import { getListEntries, ListsMenuContent } from '../lists_menu/lists_menu_content.vue'
|
||||
import { mapState, mapGetters } from 'vuex'
|
||||
import { TIMELINES, ROOT_ITEMS } from 'src/components/navigation/navigation.js'
|
||||
import { filterNavigation } from 'src/components/navigation/filter.js'
|
||||
import NavigationEntry from 'src/components/navigation/navigation_entry.vue'
|
||||
import NavigationPins from 'src/components/navigation/navigation_pins.vue'
|
||||
|
||||
import { library } from '@fortawesome/fontawesome-svg-core'
|
||||
import {
|
||||
|
@ -30,21 +33,24 @@ library.add(
|
|||
faStream,
|
||||
faList
|
||||
)
|
||||
|
||||
const NavPanel = {
|
||||
props: ['forceExpand'],
|
||||
created () {
|
||||
if (this.currentUser && this.currentUser.locked) {
|
||||
this.$store.dispatch('startFetchingFollowRequests')
|
||||
}
|
||||
},
|
||||
components: {
|
||||
TimelineMenuContent,
|
||||
ListsMenuContent
|
||||
ListsMenuContent,
|
||||
NavigationEntry,
|
||||
NavigationPins
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
showTimelines: false,
|
||||
showLists: false
|
||||
showLists: false,
|
||||
timelinesList: Object.entries(TIMELINES).map(([k, v]) => ({ ...v, name: k })),
|
||||
rootList: Object.entries(ROOT_ITEMS).map(([k, v]) => ({ ...v, name: k }))
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
@ -53,19 +59,60 @@ const NavPanel = {
|
|||
},
|
||||
toggleLists () {
|
||||
this.showLists = !this.showLists
|
||||
},
|
||||
toggleCollapse () {
|
||||
this.$store.commit('setPreference', { path: 'simple.collapseNav', value: !this.collapsed })
|
||||
this.$store.dispatch('pushServerSideStorage')
|
||||
},
|
||||
isPinned (item) {
|
||||
return this.pinnedItems.has(item)
|
||||
},
|
||||
togglePin (item) {
|
||||
if (this.isPinned(item)) {
|
||||
this.$store.commit('removeCollectionPreference', { path: 'collections.pinnedNavItems', value: item })
|
||||
} else {
|
||||
this.$store.commit('addCollectionPreference', { path: 'collections.pinnedNavItems', value: item })
|
||||
}
|
||||
this.$store.dispatch('pushServerSideStorage')
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
listsNavigation () {
|
||||
return this.$store.getters.mergedConfig.listsNavigation
|
||||
},
|
||||
...mapState({
|
||||
lists: getListEntries,
|
||||
currentUser: state => state.users.currentUser,
|
||||
followRequestCount: state => state.api.followRequests.length,
|
||||
privateMode: state => state.instance.private,
|
||||
federating: state => state.instance.federating,
|
||||
pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable
|
||||
pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable,
|
||||
pinnedItems: state => new Set(state.serverSideStorage.prefsStorage.collections.pinnedNavItems),
|
||||
collapsed: state => state.serverSideStorage.prefsStorage.simple.collapseNav
|
||||
}),
|
||||
timelinesItems () {
|
||||
return filterNavigation(
|
||||
Object
|
||||
.entries({ ...TIMELINES })
|
||||
.map(([k, v]) => ({ ...v, name: k })),
|
||||
{
|
||||
hasChats: this.pleromaChatMessagesAvailable,
|
||||
isFederating: this.federating,
|
||||
isPrivate: this.privateMode,
|
||||
currentUser: this.currentUser
|
||||
}
|
||||
)
|
||||
},
|
||||
rootItems () {
|
||||
return filterNavigation(
|
||||
Object
|
||||
.entries({ ...ROOT_ITEMS })
|
||||
.map(([k, v]) => ({ ...v, name: k })),
|
||||
{
|
||||
hasChats: this.pleromaChatMessagesAvailable,
|
||||
isFederating: this.federating,
|
||||
isPrivate: this.privateMode,
|
||||
currentUser: this.currentUser
|
||||
}
|
||||
)
|
||||
},
|
||||
...mapGetters(['unreadChatCount'])
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,27 @@
|
|||
<template>
|
||||
<div class="NavPanel">
|
||||
<div class="panel panel-default">
|
||||
<ul>
|
||||
<div
|
||||
v-if="!forceExpand"
|
||||
class="panel-heading"
|
||||
>
|
||||
<NavigationPins :limit="6" />
|
||||
<div class="spacer" />
|
||||
<button
|
||||
class="button-unstyled"
|
||||
@click="toggleCollapse"
|
||||
>
|
||||
<FAIcon
|
||||
class="timelines-chevron"
|
||||
fixed-width
|
||||
:icon="collapsed ? 'chevron-down' : 'chevron-up'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<ul
|
||||
v-if="!collapsed || forceExpand"
|
||||
class="panel-body"
|
||||
>
|
||||
<li v-if="currentUser || !privateMode">
|
||||
<button
|
||||
class="button-unstyled menu-item"
|
||||
|
@ -22,114 +42,58 @@
|
|||
v-show="showTimelines"
|
||||
class="timelines-background"
|
||||
>
|
||||
<TimelineMenuContent class="timelines" />
|
||||
<ul class="timelines">
|
||||
<NavigationEntry
|
||||
v-for="item in timelinesItems"
|
||||
:key="item.name"
|
||||
:show-pin="true"
|
||||
:item="item"
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
<li v-if="currentUser && listsNavigation">
|
||||
<li v-if="currentUser">
|
||||
<button
|
||||
class="button-unstyled menu-item"
|
||||
@click="toggleLists"
|
||||
>
|
||||
<router-link
|
||||
:to="{ name: 'lists' }"
|
||||
@click.stop
|
||||
>
|
||||
<FAIcon
|
||||
fixed-width
|
||||
class="fa-scale-110"
|
||||
icon="list"
|
||||
/>{{ $t("nav.lists") }}
|
||||
</router-link>
|
||||
<FAIcon
|
||||
fixed-width
|
||||
class="fa-scale-110"
|
||||
icon="list"
|
||||
/>{{ $t("nav.lists") }}
|
||||
<FAIcon
|
||||
class="timelines-chevron"
|
||||
fixed-width
|
||||
:icon="showLists ? 'chevron-up' : 'chevron-down'"
|
||||
/>
|
||||
<router-link
|
||||
:to="{ name: 'lists' }"
|
||||
@click.stop
|
||||
>
|
||||
<FAIcon
|
||||
class="timelines-chevron"
|
||||
fixed-width
|
||||
icon="wrench"
|
||||
/>
|
||||
</router-link>
|
||||
</button>
|
||||
<div
|
||||
v-show="showLists"
|
||||
class="timelines-background"
|
||||
>
|
||||
<ListsMenuContent class="timelines" />
|
||||
<ListsMenuContent
|
||||
:show-pin="true"
|
||||
class="timelines"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
<li v-if="currentUser && !listsNavigation">
|
||||
<router-link
|
||||
:to="{ name: 'lists' }"
|
||||
@click.stop
|
||||
>
|
||||
<button
|
||||
class="button-unstyled menu-item"
|
||||
@click="toggleLists"
|
||||
>
|
||||
<FAIcon
|
||||
fixed-width
|
||||
class="fa-scale-110"
|
||||
icon="list"
|
||||
/>{{ $t("nav.lists") }}
|
||||
</button>
|
||||
</router-link>
|
||||
</li>
|
||||
<li v-if="currentUser">
|
||||
<router-link
|
||||
class="menu-item"
|
||||
:to="{ name: 'interactions', params: { username: currentUser.screen_name } }"
|
||||
>
|
||||
<FAIcon
|
||||
fixed-width
|
||||
class="fa-scale-110"
|
||||
icon="bell"
|
||||
/>{{ $t("nav.interactions") }}
|
||||
</router-link>
|
||||
</li>
|
||||
<li v-if="currentUser && pleromaChatMessagesAvailable">
|
||||
<router-link
|
||||
class="menu-item"
|
||||
:to="{ name: 'chats', params: { username: currentUser.screen_name } }"
|
||||
>
|
||||
<div
|
||||
v-if="unreadChatCount"
|
||||
class="badge badge-notification"
|
||||
>
|
||||
{{ unreadChatCount }}
|
||||
</div>
|
||||
<FAIcon
|
||||
fixed-width
|
||||
class="fa-scale-110"
|
||||
icon="comments"
|
||||
/>{{ $t("nav.chats") }}
|
||||
</router-link>
|
||||
</li>
|
||||
<li v-if="currentUser && currentUser.locked">
|
||||
<router-link
|
||||
class="menu-item"
|
||||
:to="{ name: 'friend-requests' }"
|
||||
>
|
||||
<FAIcon
|
||||
fixed-width
|
||||
class="fa-scale-110"
|
||||
icon="user-plus"
|
||||
/>{{ $t("nav.friend_requests") }}
|
||||
<span
|
||||
v-if="followRequestCount > 0"
|
||||
class="badge badge-notification"
|
||||
>
|
||||
{{ followRequestCount }}
|
||||
</span>
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link
|
||||
class="menu-item"
|
||||
:to="{ name: 'about' }"
|
||||
>
|
||||
<FAIcon
|
||||
fixed-width
|
||||
class="fa-scale-110"
|
||||
icon="info-circle"
|
||||
/>{{ $t("nav.about") }}
|
||||
</router-link>
|
||||
</li>
|
||||
<NavigationEntry
|
||||
v-for="item in rootItems"
|
||||
:key="item.name"
|
||||
:show-pin="true"
|
||||
:item="item"
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -241,10 +205,5 @@
|
|||
margin-right: 0.8em;
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
right: 0.6rem;
|
||||
top: 1.25em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
11
src/components/navigation/filter.js
Normal file
11
src/components/navigation/filter.js
Normal file
|
@ -0,0 +1,11 @@
|
|||
export const filterNavigation = (list = [], { hasChats, isFederating, isPrivate, currentUser }) => {
|
||||
return list.filter(({ criteria, anon, anonRoute }) => {
|
||||
const set = new Set(criteria || [])
|
||||
if (!isFederating && set.has('federating')) return false
|
||||
if (isPrivate && set.has('!private')) return false
|
||||
if (!currentUser && !(anon || anonRoute)) return false
|
||||
if ((!currentUser || !currentUser.locked) && set.has('lockedUser')) return false
|
||||
if (!hasChats && set.has('chats')) return false
|
||||
return true
|
||||
})
|
||||
}
|
60
src/components/navigation/navigation.js
Normal file
60
src/components/navigation/navigation.js
Normal file
|
@ -0,0 +1,60 @@
|
|||
export const TIMELINES = {
|
||||
home: {
|
||||
route: 'friends',
|
||||
icon: 'home',
|
||||
label: 'nav.home_timeline',
|
||||
criteria: ['!private']
|
||||
},
|
||||
public: {
|
||||
route: 'public-timeline',
|
||||
anon: true,
|
||||
icon: 'users',
|
||||
label: 'nav.public_tl',
|
||||
criteria: ['!private']
|
||||
},
|
||||
twkn: {
|
||||
route: 'public-external-timeline',
|
||||
anon: true,
|
||||
icon: 'globe',
|
||||
label: 'nav.twkn',
|
||||
criteria: ['!private', 'federating']
|
||||
},
|
||||
bookmarks: {
|
||||
route: 'bookmarks',
|
||||
icon: 'bookmark',
|
||||
label: 'nav.bookmarks'
|
||||
},
|
||||
dms: {
|
||||
route: 'dms',
|
||||
icon: 'envelope',
|
||||
label: 'nav.dms'
|
||||
}
|
||||
}
|
||||
|
||||
export const ROOT_ITEMS = {
|
||||
interactions: {
|
||||
route: 'interactions',
|
||||
icon: 'bell',
|
||||
label: 'nav.interactions'
|
||||
},
|
||||
chats: {
|
||||
route: 'chats',
|
||||
icon: 'comments',
|
||||
label: 'nav.chats',
|
||||
badgeGetter: 'unreadChatCount',
|
||||
criteria: ['chats']
|
||||
},
|
||||
friendRequests: {
|
||||
route: 'friend-requests',
|
||||
icon: 'user-plus',
|
||||
label: 'nav.friend_requests',
|
||||
criteria: ['lockedUser'],
|
||||
badgeGetter: 'followRequestCount'
|
||||
},
|
||||
about: {
|
||||
route: 'about',
|
||||
anon: true,
|
||||
icon: 'info-circle',
|
||||
label: 'nav.about'
|
||||
}
|
||||
}
|
33
src/components/navigation/navigation_entry.js
Normal file
33
src/components/navigation/navigation_entry.js
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { mapState } from 'vuex'
|
||||
import { library } from '@fortawesome/fontawesome-svg-core'
|
||||
import { faThumbtack } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
library.add(faThumbtack)
|
||||
|
||||
const NavigationEntry = {
|
||||
props: ['item', 'showPin'],
|
||||
methods: {
|
||||
isPinned (value) {
|
||||
return this.pinnedItems.has(value)
|
||||
},
|
||||
togglePin (value) {
|
||||
if (this.isPinned(value)) {
|
||||
this.$store.commit('removeCollectionPreference', { path: 'collections.pinnedNavItems', value })
|
||||
} else {
|
||||
this.$store.commit('addCollectionPreference', { path: 'collections.pinnedNavItems', value })
|
||||
}
|
||||
this.$store.dispatch('pushServerSideStorage')
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
getters () {
|
||||
return this.$store.getters
|
||||
},
|
||||
...mapState({
|
||||
currentUser: state => state.users.currentUser,
|
||||
pinnedItems: state => new Set(state.serverSideStorage.prefsStorage.collections.pinnedNavItems)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default NavigationEntry
|
96
src/components/navigation/navigation_entry.vue
Normal file
96
src/components/navigation/navigation_entry.vue
Normal file
|
@ -0,0 +1,96 @@
|
|||
<template>
|
||||
<li class="NavigationEntry">
|
||||
<router-link
|
||||
class="menu-item"
|
||||
:to="item.routeObject || { name: (currentUser || item.anon) ? item.route : item.anonRoute, params: { username: currentUser.screen_name } }"
|
||||
>
|
||||
<FAIcon
|
||||
v-if="item.icon"
|
||||
fixed-width
|
||||
class="fa-scale-110"
|
||||
:icon="item.icon"
|
||||
/>
|
||||
<span
|
||||
v-if="item.iconLetter"
|
||||
class="icon iconLetter fa-scale-110"
|
||||
>{{ item.iconLetter }}
|
||||
</span>{{ item.labelRaw || $t(item.label) }}
|
||||
<button
|
||||
type="button"
|
||||
class="button-unstyled"
|
||||
@click.stop.prevent="togglePin(item.name)"
|
||||
>
|
||||
<FAIcon
|
||||
v-if="showPin && currentUser"
|
||||
fixed-width
|
||||
class="fa-scale-110"
|
||||
:class="{ 'veryfaint': !isPinned(item.name) }"
|
||||
:transform="!isPinned(item.name) ? 'rotate-45' : ''"
|
||||
icon="thumbtack"
|
||||
/>
|
||||
<div
|
||||
v-if="item.badgeGetter && getters[item.badgeGetter]"
|
||||
class="badge badge-notification"
|
||||
>
|
||||
{{ getters[item.badgeGetter] }}
|
||||
</div>
|
||||
</button>
|
||||
</router-link>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script src="./navigation_entry.js"></script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../_variables.scss';
|
||||
|
||||
.NavigationEntry {
|
||||
.fa-scale-110 {
|
||||
margin-right: 0.8em;
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
right: 0.6rem;
|
||||
top: 1.25em;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
height: 3.5em;
|
||||
line-height: 3.5em;
|
||||
padding: 0 1em;
|
||||
width: 100%;
|
||||
color: $fallback--link;
|
||||
color: var(--link, $fallback--link);
|
||||
|
||||
&:hover {
|
||||
background-color: $fallback--lightBg;
|
||||
background-color: var(--selectedMenu, $fallback--lightBg);
|
||||
color: $fallback--link;
|
||||
color: var(--selectedMenuText, $fallback--link);
|
||||
--faint: var(--selectedMenuFaintText, $fallback--faint);
|
||||
--faintLink: var(--selectedMenuFaintLink, $fallback--faint);
|
||||
--lightText: var(--selectedMenuLightText, $fallback--lightText);
|
||||
--icon: var(--selectedMenuIcon, $fallback--icon);
|
||||
}
|
||||
|
||||
&.router-link-active {
|
||||
font-weight: bolder;
|
||||
background-color: $fallback--lightBg;
|
||||
background-color: var(--selectedMenu, $fallback--lightBg);
|
||||
color: $fallback--text;
|
||||
color: var(--selectedMenuText, $fallback--text);
|
||||
--faint: var(--selectedMenuFaintText, $fallback--faint);
|
||||
--faintLink: var(--selectedMenuFaintLink, $fallback--faint);
|
||||
--lightText: var(--selectedMenuLightText, $fallback--lightText);
|
||||
--icon: var(--selectedMenuIcon, $fallback--icon);
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
76
src/components/navigation/navigation_pins.js
Normal file
76
src/components/navigation/navigation_pins.js
Normal file
|
@ -0,0 +1,76 @@
|
|||
import { getListEntries } from '../lists_menu/lists_menu_content.vue'
|
||||
import { mapState } from 'vuex'
|
||||
import { TIMELINES, ROOT_ITEMS } from 'src/components/navigation/navigation.js'
|
||||
import { filterNavigation } from 'src/components/navigation/filter.js'
|
||||
|
||||
import { library } from '@fortawesome/fontawesome-svg-core'
|
||||
import {
|
||||
faUsers,
|
||||
faGlobe,
|
||||
faBookmark,
|
||||
faEnvelope,
|
||||
faComments,
|
||||
faBell,
|
||||
faInfoCircle,
|
||||
faStream,
|
||||
faList
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
library.add(
|
||||
faUsers,
|
||||
faGlobe,
|
||||
faBookmark,
|
||||
faEnvelope,
|
||||
faComments,
|
||||
faBell,
|
||||
faInfoCircle,
|
||||
faStream,
|
||||
faList
|
||||
)
|
||||
const NavPanel = {
|
||||
props: ['limit'],
|
||||
computed: {
|
||||
getters () {
|
||||
return this.$store.getters
|
||||
},
|
||||
...mapState({
|
||||
lists: getListEntries,
|
||||
currentUser: state => state.users.currentUser,
|
||||
followRequestCount: state => state.api.followRequests.length,
|
||||
privateMode: state => state.instance.private,
|
||||
federating: state => state.instance.federating,
|
||||
pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable,
|
||||
pinnedItems: state => new Set(state.serverSideStorage.prefsStorage.collections.pinnedNavItems)
|
||||
}),
|
||||
pinnedList () {
|
||||
if (!this.currentUser) {
|
||||
return [
|
||||
{ ...TIMELINES.public, name: 'public' },
|
||||
{ ...TIMELINES.twkn, name: 'twkn' },
|
||||
{ ...ROOT_ITEMS.about, name: 'about' }
|
||||
]
|
||||
}
|
||||
return filterNavigation(
|
||||
[
|
||||
...Object
|
||||
.entries({ ...TIMELINES })
|
||||
.filter(([k]) => this.pinnedItems.has(k))
|
||||
.map(([k, v]) => ({ ...v, name: k })),
|
||||
...this.lists.filter((k) => this.pinnedItems.has(k.name)),
|
||||
...Object
|
||||
.entries({ ...ROOT_ITEMS })
|
||||
.filter(([k]) => this.pinnedItems.has(k))
|
||||
.map(([k, v]) => ({ ...v, name: k }))
|
||||
],
|
||||
{
|
||||
hasChats: this.pleromaChatMessagesAvailable,
|
||||
isFederating: this.federating,
|
||||
isPrivate: this.privateMode,
|
||||
currentUser: this.currentUser
|
||||
}
|
||||
).slice(0, this.limit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default NavPanel
|
67
src/components/navigation/navigation_pins.vue
Normal file
67
src/components/navigation/navigation_pins.vue
Normal file
|
@ -0,0 +1,67 @@
|
|||
<template>
|
||||
<span class="NavigationPins">
|
||||
<router-link
|
||||
v-for="item in pinnedList"
|
||||
:key="item.name"
|
||||
class="pinned-item"
|
||||
:to="item.routeObject || { name: (currentUser || item.anon) ? item.route : item.anonRoute, params: { username: currentUser.screen_name } }"
|
||||
>
|
||||
<FAIcon
|
||||
v-if="item.icon"
|
||||
fixed-width
|
||||
:icon="item.icon"
|
||||
/>
|
||||
<span
|
||||
v-if="item.iconLetter"
|
||||
class="iconLetter fa-scale-110 fa-old-padding"
|
||||
>{{ item.iconLetter }}</span>
|
||||
<div
|
||||
v-if="item.badgeGetter && getters[item.badgeGetter]"
|
||||
class="alert-dot"
|
||||
/>
|
||||
</router-link>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script src="./navigation_pins.js"></script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../_variables.scss';
|
||||
.NavigationPins {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
|
||||
.alert-dot {
|
||||
border-radius: 100%;
|
||||
height: 0.5em;
|
||||
width: 0.5em;
|
||||
position: absolute;
|
||||
right: calc(50% - 0.25em);
|
||||
top: calc(50% - 0.25em);
|
||||
margin-left: 6px;
|
||||
margin-top: -6px;
|
||||
background-color: $fallback--cRed;
|
||||
background-color: var(--badgeNotification, $fallback--cRed);
|
||||
}
|
||||
|
||||
.pinned-item {
|
||||
position: relative;
|
||||
flex: 0 0 3em;
|
||||
min-width: 2em;
|
||||
text-align: center;
|
||||
|
||||
& .svg-inline--fa,
|
||||
& .iconLetter {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&.router-link-active {
|
||||
& .svg-inline--fa,
|
||||
& .iconLetter {
|
||||
color: $fallback--text;
|
||||
color: var(--selectedMenuText, $fallback--text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -47,6 +47,8 @@
|
|||
class="cancel-icon fa-scale-110 fa-old-padding"
|
||||
/>
|
||||
</button>
|
||||
<span class="spacer" />
|
||||
<span class="spacer" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -124,11 +124,6 @@
|
|||
{{ $t('settings.hide_shoutbox') }}
|
||||
</BooleanSetting>
|
||||
</li>
|
||||
<li>
|
||||
<BooleanSetting path="listsNavigation">
|
||||
{{ $t('settings.lists_navigation') }}
|
||||
</BooleanSetting>
|
||||
</li>
|
||||
<li>
|
||||
<h3>{{ $t('settings.columns') }}</h3>
|
||||
</li>
|
||||
|
|
|
@ -15,6 +15,7 @@ import {
|
|||
faTachometerAlt,
|
||||
faCog,
|
||||
faInfoCircle,
|
||||
faCompass,
|
||||
faList
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
|
@ -30,6 +31,7 @@ library.add(
|
|||
faTachometerAlt,
|
||||
faCog,
|
||||
faInfoCircle,
|
||||
faCompass,
|
||||
faList
|
||||
)
|
||||
|
||||
|
|
|
@ -191,6 +191,18 @@
|
|||
/> {{ $t("nav.administration") }}
|
||||
</a>
|
||||
</li>
|
||||
<li
|
||||
v-if="currentUser"
|
||||
@click="toggleDrawer"
|
||||
>
|
||||
<router-link :to="{ name: 'edit-navigation' }">
|
||||
<FAIcon
|
||||
fixed-width
|
||||
class="fa-scale-110 fa-old-padding"
|
||||
icon="compass"
|
||||
/> {{ $t("nav.edit_nav_mobile") }}
|
||||
</router-link>
|
||||
</li>
|
||||
<li
|
||||
v-if="currentUser"
|
||||
@click="toggleDrawer"
|
||||
|
|
|
@ -28,6 +28,7 @@ const Timeline = {
|
|||
'footerSlipgate' // reference to an element where we should put our footer
|
||||
],
|
||||
data () {
|
||||
console.log(this.timelineName)
|
||||
return {
|
||||
paused: false,
|
||||
unfocused: false,
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
<template>
|
||||
<div :class="['Timeline', classes.root]">
|
||||
<div :class="classes.header">
|
||||
<TimelineMenu v-if="!embedded" />
|
||||
<TimelineMenu
|
||||
v-if="!embedded"
|
||||
:timeline-name="timelineName"
|
||||
/>
|
||||
<button
|
||||
v-if="showLoadButton"
|
||||
class="button-default loadmore-button"
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import Popover from '../popover/popover.vue'
|
||||
import TimelineMenuContent from './timeline_menu_content.vue'
|
||||
import NavigationEntry from 'src/components/navigation/navigation_entry.vue'
|
||||
import { ListsMenuContent } from '../lists_menu/lists_menu_content.vue'
|
||||
import { library } from '@fortawesome/fontawesome-svg-core'
|
||||
import { TIMELINES } from 'src/components/navigation/navigation.js'
|
||||
import {
|
||||
faChevronDown
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
|
@ -22,11 +24,13 @@ export const timelineNames = () => {
|
|||
const TimelineMenu = {
|
||||
components: {
|
||||
Popover,
|
||||
TimelineMenuContent
|
||||
NavigationEntry,
|
||||
ListsMenuContent
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
isOpen: false
|
||||
isOpen: false,
|
||||
timelinesList: Object.entries(TIMELINES).map(([k, v]) => ({ ...v, name: k }))
|
||||
}
|
||||
},
|
||||
created () {
|
||||
|
@ -34,6 +38,12 @@ const TimelineMenu = {
|
|||
this.$store.dispatch('setLastTimeline', this.$route.name)
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
useListsMenu () {
|
||||
const route = this.$route.name
|
||||
return route === 'lists-timeline'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
openMenu () {
|
||||
// $nextTick is too fast, animation won't play back but
|
||||
|
|
|
@ -10,7 +10,19 @@
|
|||
@close="() => isOpen = false"
|
||||
>
|
||||
<template #content>
|
||||
<TimelineMenuContent />
|
||||
<ListsMenuContent
|
||||
v-if="useListsMenu"
|
||||
:show-pin="false"
|
||||
class="timelines"
|
||||
/>
|
||||
<ul v-else>
|
||||
<NavigationEntry
|
||||
v-for="item in timelinesList"
|
||||
:key="item.name"
|
||||
:show-pin="false"
|
||||
:item="item"
|
||||
/>
|
||||
</ul>
|
||||
</template>
|
||||
<template #trigger>
|
||||
<span class="button-unstyled title timeline-menu-title">
|
||||
|
@ -138,8 +150,7 @@
|
|||
background-color: $fallback--lightBg;
|
||||
background-color: var(--selectedMenu, $fallback--lightBg);
|
||||
color: $fallback--text;
|
||||
color: var(--selectedMenuText, $fallback--text);
|
||||
--faint: var(--selectedMenuFaintText, $fallback--faint);
|
||||
color: var(--selectedMenuText, $fallback--text); --faint: var(--selectedMenuFaintText, $fallback--faint);
|
||||
--faintLink: var(--selectedMenuFaintLink, $fallback--faint);
|
||||
--lightText: var(--selectedMenuLightText, $fallback--lightText);
|
||||
--icon: var(--selectedMenuIcon, $fallback--icon);
|
||||
|
|
|
@ -1,29 +0,0 @@
|
|||
import { mapState } from 'vuex'
|
||||
import { library } from '@fortawesome/fontawesome-svg-core'
|
||||
import {
|
||||
faUsers,
|
||||
faGlobe,
|
||||
faBookmark,
|
||||
faEnvelope,
|
||||
faHome
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
library.add(
|
||||
faUsers,
|
||||
faGlobe,
|
||||
faBookmark,
|
||||
faEnvelope,
|
||||
faHome
|
||||
)
|
||||
|
||||
const TimelineMenuContent = {
|
||||
computed: {
|
||||
...mapState({
|
||||
currentUser: state => state.users.currentUser,
|
||||
privateMode: state => state.instance.private,
|
||||
federating: state => state.instance.federating
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default TimelineMenuContent
|
|
@ -1,66 +0,0 @@
|
|||
<template>
|
||||
<ul>
|
||||
<li v-if="currentUser">
|
||||
<router-link
|
||||
class="menu-item"
|
||||
:to="{ name: 'friends' }"
|
||||
>
|
||||
<FAIcon
|
||||
fixed-width
|
||||
class="fa-scale-110 fa-old-padding "
|
||||
icon="home"
|
||||
/>{{ $t("nav.home_timeline") }}
|
||||
</router-link>
|
||||
</li>
|
||||
<li v-if="currentUser || !privateMode">
|
||||
<router-link
|
||||
class="menu-item"
|
||||
:to="{ name: 'public-timeline' }"
|
||||
>
|
||||
<FAIcon
|
||||
fixed-width
|
||||
class="fa-scale-110 fa-old-padding "
|
||||
icon="users"
|
||||
/>{{ $t("nav.public_tl") }}
|
||||
</router-link>
|
||||
</li>
|
||||
<li v-if="federating && (currentUser || !privateMode)">
|
||||
<router-link
|
||||
class="menu-item"
|
||||
:to="{ name: 'public-external-timeline' }"
|
||||
>
|
||||
<FAIcon
|
||||
fixed-width
|
||||
class="fa-scale-110 fa-old-padding "
|
||||
icon="globe"
|
||||
/>{{ $t("nav.twkn") }}
|
||||
</router-link>
|
||||
</li>
|
||||
<li v-if="currentUser">
|
||||
<router-link
|
||||
class="menu-item"
|
||||
:to="{ name: 'bookmarks'}"
|
||||
>
|
||||
<FAIcon
|
||||
fixed-width
|
||||
class="fa-scale-110 fa-old-padding "
|
||||
icon="bookmark"
|
||||
/>{{ $t("nav.bookmarks") }}
|
||||
</router-link>
|
||||
</li>
|
||||
<li v-if="currentUser">
|
||||
<router-link
|
||||
class="menu-item"
|
||||
:to="{ name: 'dms', params: { username: currentUser.screen_name } }"
|
||||
>
|
||||
<FAIcon
|
||||
fixed-width
|
||||
class="fa-scale-110 fa-old-padding "
|
||||
icon="envelope"
|
||||
/>{{ $t("nav.dms") }}
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script src="./timeline_menu_content.js"></script>
|
|
@ -38,7 +38,7 @@ const UpdateNotification = {
|
|||
return !this.$store.state.instance.disableUpdateNotification &&
|
||||
this.$store.state.users.currentUser &&
|
||||
this.$store.state.serverSideStorage.flagStorage.updateCounter < CURRENT_UPDATE_COUNTER &&
|
||||
!this.$store.state.serverSideStorage.flagStorage.dontShowUpdateNotifs
|
||||
!this.$store.state.serverSideStorage.prefsStorage.simple.dontShowUpdateNotifs
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
@ -48,7 +48,7 @@ const UpdateNotification = {
|
|||
neverShowAgain () {
|
||||
this.toggleShow()
|
||||
this.$store.commit('setFlag', { flag: 'updateCounter', value: CURRENT_UPDATE_COUNTER })
|
||||
this.$store.commit('setFlag', { flag: 'dontShowUpdateNotifs', value: 1 })
|
||||
this.$store.commit('setPreference', { path: 'simple.dontShowUpdateNotifs', value: true })
|
||||
this.$store.dispatch('pushServerSideStorage')
|
||||
},
|
||||
dismiss () {
|
||||
|
|
|
@ -60,7 +60,7 @@
|
|||
<template #linkToArtist>
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://post.ebin.club/pipivovott"
|
||||
href="https://post.ebin.club/users/pipivovott"
|
||||
>pipivovott</a>
|
||||
</template>
|
||||
</i18n-t>
|
||||
|
|
|
@ -149,7 +149,8 @@
|
|||
"preferences": "Preferences",
|
||||
"timelines": "Timelines",
|
||||
"chats": "Chats",
|
||||
"lists": "Lists"
|
||||
"lists": "Lists",
|
||||
"edit_nav_mobile": "Customize navigation bar"
|
||||
},
|
||||
"notifications": {
|
||||
"broken_favorite": "Unknown status, searching for it…",
|
||||
|
|
|
@ -15,6 +15,9 @@ const api = {
|
|||
mastoUserSocketStatus: null,
|
||||
followRequests: []
|
||||
},
|
||||
getters: {
|
||||
followRequestCount: state => state.api.followRequests.length
|
||||
},
|
||||
mutations: {
|
||||
setBackendInteractor (state, backendInteractor) {
|
||||
state.backendInteractor = backendInteractor
|
||||
|
|
|
@ -87,7 +87,6 @@ export const defaultState = {
|
|||
sidebarColumnWidth: '25rem',
|
||||
contentColumnWidth: '45rem',
|
||||
notifsColumnWidth: '25rem',
|
||||
listsNavigation: false,
|
||||
greentext: undefined, // instance default
|
||||
useAtIcon: undefined, // instance default
|
||||
mentionLinkDisplay: undefined, // instance default
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { toRaw } from 'vue'
|
||||
import { isEqual, cloneDeep } from 'lodash'
|
||||
import { isEqual, uniqWith, cloneDeep, set, get, clamp } from 'lodash'
|
||||
import { CURRENT_UPDATE_COUNTER } from 'src/components/update_notification/update_notification.js'
|
||||
|
||||
export const VERSION = 1
|
||||
|
@ -14,14 +14,21 @@ export const defaultState = {
|
|||
// storage of flags - stuff that can only be set and incremented
|
||||
flagStorage: {
|
||||
updateCounter: 0, // Counter for most recent update notification seen
|
||||
// TODO move to prefsStorage when that becomes a thing since only way
|
||||
// this can be reset is by complete reset of all flags
|
||||
dontShowUpdateNotifs: 0, // if user chose to not show update notifications ever again
|
||||
reset: 0 // special flag that can be used to force-reset all flags, debug purposes only
|
||||
// special reset codes:
|
||||
// 1000: trim keys to those known by currently running FE
|
||||
// 1001: same as above + reset everything to 0
|
||||
},
|
||||
prefsStorage: {
|
||||
_journal: [],
|
||||
simple: {
|
||||
dontShowUpdateNotifs: false,
|
||||
collapseNav: false
|
||||
},
|
||||
collections: {
|
||||
pinnedNavItems: ['home', 'dms', 'chats', 'about']
|
||||
}
|
||||
},
|
||||
// raw data
|
||||
raw: null,
|
||||
// local cache
|
||||
|
@ -33,14 +40,43 @@ export const newUserFlags = {
|
|||
updateCounter: CURRENT_UPDATE_COUNTER // new users don't need to see update notification
|
||||
}
|
||||
|
||||
const _wrapData = (data) => ({
|
||||
export const _moveItemInArray = (array, value, movement) => {
|
||||
const oldIndex = array.indexOf(value)
|
||||
const newIndex = oldIndex + movement
|
||||
const newArray = [...array]
|
||||
// remove old
|
||||
newArray.splice(oldIndex, 1)
|
||||
// add new
|
||||
newArray.splice(clamp(newIndex, 0, newArray.length + 1), 0, value)
|
||||
return newArray
|
||||
}
|
||||
|
||||
const _wrapData = (data, userName) => ({
|
||||
...data,
|
||||
_user: userName,
|
||||
_timestamp: Date.now(),
|
||||
_version: VERSION
|
||||
})
|
||||
|
||||
const _checkValidity = (data) => data._timestamp > 0 && data._version > 0
|
||||
|
||||
const _verifyPrefs = (state) => {
|
||||
state.prefsStorage = state.prefsStorage || {
|
||||
simple: {},
|
||||
collections: {}
|
||||
}
|
||||
Object.entries(defaultState.prefsStorage.simple).forEach(([k, v]) => {
|
||||
if (typeof v === 'number' || typeof v === 'boolean') return
|
||||
console.warn(`Preference simple.${k} as invalid type, reinitializing`)
|
||||
set(state.prefsStorage.simple, k, defaultState.prefsStorage.simple[k])
|
||||
})
|
||||
Object.entries(defaultState.prefsStorage.collections).forEach(([k, v]) => {
|
||||
if (Array.isArray(v)) return
|
||||
console.warn(`Preference collections.${k} as invalid type, reinitializing`)
|
||||
set(state.prefsStorage.collections, k, defaultState.prefsStorage.collections[k])
|
||||
})
|
||||
}
|
||||
|
||||
export const _getRecentData = (cache, live) => {
|
||||
const result = { recent: null, stale: null, needUpload: false }
|
||||
const cacheValid = _checkValidity(cache || {})
|
||||
|
@ -85,6 +121,8 @@ export const _getAllFlags = (recent, stale) => {
|
|||
}
|
||||
|
||||
export const _mergeFlags = (recent, stale, allFlagKeys) => {
|
||||
if (!stale.flagStorage) return recent.flagStorage
|
||||
if (!recent.flagStorage) return stale.flagStorage
|
||||
return Object.fromEntries(allFlagKeys.map(flag => {
|
||||
const recentFlag = recent.flagStorage[flag]
|
||||
const staleFlag = stale.flagStorage[flag]
|
||||
|
@ -93,6 +131,74 @@ export const _mergeFlags = (recent, stale, allFlagKeys) => {
|
|||
}))
|
||||
}
|
||||
|
||||
const _mergeJournal = (a, b) => uniqWith(
|
||||
// TODO use groupBy to group by path, then trim them depending on operations,
|
||||
// i.e. if field got removed in the end - no need to sort it beforehand, if field
|
||||
// got re-added no need to remove it and add it etc.
|
||||
[
|
||||
...(Array.isArray(a) ? a : []),
|
||||
...(Array.isArray(b) ? b : [])
|
||||
].sort((a, b) => a.timestamp > b.timestamp ? -1 : 1),
|
||||
(a, b) => {
|
||||
if (a.operation !== b.operation) return false
|
||||
switch (a.operation) {
|
||||
case 'set':
|
||||
case 'arrangeSet':
|
||||
return a.path === b.path
|
||||
default:
|
||||
return a.path === b.path && a.timestamp === b.timestamp
|
||||
}
|
||||
}
|
||||
).reverse()
|
||||
|
||||
export const _mergePrefs = (recent, stale, allFlagKeys) => {
|
||||
if (!stale) return recent
|
||||
if (!recent) return stale
|
||||
const { _journal: recentJournal, ...recentData } = recent
|
||||
const { _journal: staleJournal } = stale
|
||||
/** Journal entry format:
|
||||
* path: path to entry in prefsStorage
|
||||
* timestamp: timestamp of the change
|
||||
* operation: operation type
|
||||
* arguments: array of arguments, depends on operation type
|
||||
*
|
||||
* currently only supported operation type is "set" which just sets the value
|
||||
* to requested one. Intended only to be used with simple preferences (boolean, number)
|
||||
* shouldn't be used with collections!
|
||||
*/
|
||||
const resultOutput = { ...recentData }
|
||||
const totalJournal = _mergeJournal(staleJournal, recentJournal)
|
||||
totalJournal.forEach(({ path, timestamp, operation, command, args }) => {
|
||||
operation = operation || command
|
||||
if (path.startsWith('_')) {
|
||||
console.error(`journal contains entry to edit internal (starts with _) field '${path}', something is incorrect here, ignoring.`)
|
||||
return
|
||||
}
|
||||
switch (operation) {
|
||||
case 'set':
|
||||
set(resultOutput, path, args[0])
|
||||
break
|
||||
case 'addToCollection':
|
||||
set(resultOutput, path, Array.from(new Set(get(resultOutput, path)).add(args[0])))
|
||||
break
|
||||
case 'removeFromCollection': {
|
||||
const newSet = new Set(get(resultOutput, path))
|
||||
newSet.delete(args[0])
|
||||
set(resultOutput, path, Array.from(newSet))
|
||||
break
|
||||
}
|
||||
case 'reorderCollection': {
|
||||
const [value, movement] = args
|
||||
set(resultOutput, path, _moveItemInArray(get(resultOutput, path), value, movement))
|
||||
break
|
||||
}
|
||||
default:
|
||||
console.error(`Unknown journal operation: '${operation}', did we forget to run reverse migrations beforehand?`)
|
||||
}
|
||||
})
|
||||
return { ...resultOutput, _journal: totalJournal }
|
||||
}
|
||||
|
||||
export const _resetFlags = (totalFlags, knownKeys = defaultState.flagStorage) => {
|
||||
let result = { ...totalFlags }
|
||||
const allFlagKeys = Object.keys(totalFlags)
|
||||
|
@ -149,10 +255,17 @@ export const _doMigrations = (cache) => {
|
|||
}
|
||||
|
||||
export const mutations = {
|
||||
clearServerSideStorage (state, userData) {
|
||||
state = { ...cloneDeep(defaultState) }
|
||||
},
|
||||
setServerSideStorage (state, userData) {
|
||||
const live = userData.storage
|
||||
state.raw = live
|
||||
let cache = state.cache
|
||||
if (cache._user !== userData.fqn) {
|
||||
console.warn('cache belongs to another user! reinitializing local cache!')
|
||||
cache = null
|
||||
}
|
||||
|
||||
cache = _doMigrations(cache)
|
||||
|
||||
|
@ -165,7 +278,8 @@ export const mutations = {
|
|||
if (recent === null) {
|
||||
console.debug(`Data is empty, initializing for ${userNew ? 'new' : 'existing'} user`)
|
||||
recent = _wrapData({
|
||||
flagStorage: { ...flagsTemplate }
|
||||
flagStorage: { ...flagsTemplate },
|
||||
prefsStorage: { ...defaultState.prefsStorage }
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -180,17 +294,23 @@ export const mutations = {
|
|||
|
||||
const allFlagKeys = _getAllFlags(recent, stale)
|
||||
let totalFlags
|
||||
let totalPrefs
|
||||
if (dirty) {
|
||||
// Merge the flags
|
||||
console.debug('Merging the flags...')
|
||||
console.debug('Merging the data...')
|
||||
totalFlags = _mergeFlags(recent, stale, allFlagKeys)
|
||||
_verifyPrefs(recent)
|
||||
_verifyPrefs(stale)
|
||||
totalPrefs = _mergePrefs(recent.prefsStorage, stale.prefsStorage)
|
||||
} else {
|
||||
totalFlags = recent.flagStorage
|
||||
totalPrefs = recent.prefsStorage
|
||||
}
|
||||
|
||||
totalFlags = _resetFlags(totalFlags)
|
||||
|
||||
recent.flagStorage = totalFlags
|
||||
recent.flagStorage = { ...flagsTemplate, ...totalFlags }
|
||||
recent.prefsStorage = { ...defaultState.prefsStorage, ...totalPrefs }
|
||||
|
||||
state.dirty = dirty || needsUpload
|
||||
state.cache = recent
|
||||
|
@ -199,10 +319,72 @@ export const mutations = {
|
|||
state.cache._timestamp = Math.min(stale._timestamp, recent._timestamp)
|
||||
}
|
||||
state.flagStorage = state.cache.flagStorage
|
||||
state.prefsStorage = state.cache.prefsStorage
|
||||
},
|
||||
setFlag (state, { flag, value }) {
|
||||
state.flagStorage[flag] = value
|
||||
state.dirty = true
|
||||
},
|
||||
setPreference (state, { path, value }) {
|
||||
if (path.startsWith('_')) {
|
||||
console.error(`tried to edit internal (starts with _) field '${path}', ignoring.`)
|
||||
return
|
||||
}
|
||||
set(state.prefsStorage, path, value)
|
||||
state.prefsStorage._journal = [
|
||||
...state.prefsStorage._journal,
|
||||
{ operation: 'set', path, args: [value], timestamp: Date.now() }
|
||||
]
|
||||
state.dirty = true
|
||||
},
|
||||
addCollectionPreference (state, { path, value }) {
|
||||
if (path.startsWith('_')) {
|
||||
console.error(`tried to edit internal (starts with _) field '${path}', ignoring.`)
|
||||
return
|
||||
}
|
||||
const collection = new Set(get(state.prefsStorage, path))
|
||||
collection.add(value)
|
||||
set(state.prefsStorage, path, [...collection])
|
||||
state.prefsStorage._journal = [
|
||||
...state.prefsStorage._journal,
|
||||
{ operation: 'addToCollection', path, args: [value], timestamp: Date.now() }
|
||||
]
|
||||
state.dirty = true
|
||||
},
|
||||
removeCollectionPreference (state, { path, value }) {
|
||||
if (path.startsWith('_')) {
|
||||
console.error(`tried to edit internal (starts with _) field '${path}', ignoring.`)
|
||||
return
|
||||
}
|
||||
const collection = new Set(get(state.prefsStorage, path))
|
||||
collection.delete(value)
|
||||
set(state.prefsStorage, path, [...collection])
|
||||
state.prefsStorage._journal = [
|
||||
...state.prefsStorage._journal,
|
||||
{ operation: 'removeFromCollection', path, args: [value], timestamp: Date.now() }
|
||||
]
|
||||
state.dirty = true
|
||||
},
|
||||
reorderCollectionPreference (state, { path, value, movement }) {
|
||||
if (path.startsWith('_')) {
|
||||
console.error(`tried to edit internal (starts with _) field '${path}', ignoring.`)
|
||||
return
|
||||
}
|
||||
const collection = get(state.prefsStorage, path)
|
||||
const newCollection = _moveItemInArray(collection, value, movement)
|
||||
set(state.prefsStorage, path, newCollection)
|
||||
state.prefsStorage._journal = [
|
||||
...state.prefsStorage._journal,
|
||||
{ operation: 'arrangeCollection', path, args: [value], timestamp: Date.now() }
|
||||
]
|
||||
state.dirty = true
|
||||
},
|
||||
updateCache (state, { username }) {
|
||||
state.prefsStorage._journal = _mergeJournal(state.prefsStorage._journal)
|
||||
state.cache = _wrapData({
|
||||
flagStorage: toRaw(state.flagStorage),
|
||||
prefsStorage: toRaw(state.prefsStorage)
|
||||
}, username)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -214,15 +396,16 @@ const serverSideStorage = {
|
|||
actions: {
|
||||
pushServerSideStorage ({ state, rootState, commit }, { force = false } = {}) {
|
||||
const needPush = state.dirty || force
|
||||
console.log(needPush)
|
||||
if (!needPush) return
|
||||
state.cache = _wrapData({
|
||||
flagStorage: toRaw(state.flagStorage)
|
||||
})
|
||||
commit('updateCache', { username: rootState.users.currentUser.fqn })
|
||||
const params = { pleroma_settings_store: { 'pleroma-fe': state.cache } }
|
||||
rootState.api.backendInteractor
|
||||
.updateProfile({ params })
|
||||
.then((user) => commit('setServerSideStorage', user))
|
||||
state.dirty = false
|
||||
.then((user) => {
|
||||
commit('setServerSideStorage', user)
|
||||
state.dirty = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -509,6 +509,7 @@ const users = {
|
|||
store.dispatch('setLastTimeline', 'public-timeline')
|
||||
store.dispatch('setLayoutWidth', windowWidth())
|
||||
store.dispatch('setLayoutHeight', windowHeight())
|
||||
store.commit('clearServerSideStorage')
|
||||
})
|
||||
},
|
||||
loginUser (store, accessToken) {
|
||||
|
|
|
@ -48,6 +48,7 @@ export const parseUser = (data) => {
|
|||
|
||||
if (masto) {
|
||||
output.screen_name = data.acct
|
||||
output.fqn = data.fqn
|
||||
output.statusnet_profile_url = data.url
|
||||
|
||||
// There's nothing else to get
|
||||
|
|
|
@ -4,9 +4,11 @@ import {
|
|||
VERSION,
|
||||
COMMAND_TRIM_FLAGS,
|
||||
COMMAND_TRIM_FLAGS_AND_RESET,
|
||||
_moveItemInArray,
|
||||
_getRecentData,
|
||||
_getAllFlags,
|
||||
_mergeFlags,
|
||||
_mergePrefs,
|
||||
_resetFlags,
|
||||
mutations,
|
||||
defaultState,
|
||||
|
@ -28,6 +30,7 @@ describe('The serverSideStorage module', () => {
|
|||
expect(state.cache._version).to.eql(VERSION)
|
||||
expect(state.cache._timestamp).to.be.a('number')
|
||||
expect(state.cache.flagStorage).to.eql(defaultState.flagStorage)
|
||||
expect(state.cache.prefsStorage).to.eql(defaultState.prefsStorage)
|
||||
})
|
||||
|
||||
it('should initialize storage with proper flags for new users if none present', () => {
|
||||
|
@ -36,6 +39,7 @@ describe('The serverSideStorage module', () => {
|
|||
expect(state.cache._version).to.eql(VERSION)
|
||||
expect(state.cache._timestamp).to.be.a('number')
|
||||
expect(state.cache.flagStorage).to.eql(newUserFlags)
|
||||
expect(state.cache.prefsStorage).to.eql(defaultState.prefsStorage)
|
||||
})
|
||||
|
||||
it('should merge flags even if remote timestamp is older', () => {
|
||||
|
@ -57,6 +61,9 @@ describe('The serverSideStorage module', () => {
|
|||
flagStorage: {
|
||||
...defaultState.flagStorage,
|
||||
updateCounter: 1
|
||||
},
|
||||
prefsStorage: {
|
||||
...defaultState.prefsStorage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -99,9 +106,52 @@ describe('The serverSideStorage module', () => {
|
|||
expect(state.cache.flagStorage).to.eql(defaultState.flagStorage)
|
||||
})
|
||||
})
|
||||
describe('setPreference', () => {
|
||||
const { setPreference, updateCache } = mutations
|
||||
|
||||
it('should set preference and update journal log accordingly', () => {
|
||||
const state = cloneDeep(defaultState)
|
||||
setPreference(state, { path: 'simple.testing', value: 1 })
|
||||
expect(state.prefsStorage.simple.testing).to.eql(1)
|
||||
expect(state.prefsStorage._journal.length).to.eql(1)
|
||||
expect(state.prefsStorage._journal[0]).to.eql({
|
||||
path: 'simple.testing',
|
||||
operation: 'set',
|
||||
args: [1],
|
||||
// should have A timestamp, we don't really care what it is
|
||||
timestamp: state.prefsStorage._journal[0].timestamp
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep journal to a minimum (one entry per path for sets)', () => {
|
||||
const state = cloneDeep(defaultState)
|
||||
setPreference(state, { path: 'simple.testing', value: 1 })
|
||||
setPreference(state, { path: 'simple.testing', value: 2 })
|
||||
updateCache(state, { username: 'test' })
|
||||
expect(state.prefsStorage.simple.testing).to.eql(2)
|
||||
expect(state.prefsStorage._journal.length).to.eql(1)
|
||||
expect(state.prefsStorage._journal[0]).to.eql({
|
||||
path: 'simple.testing',
|
||||
operation: 'set',
|
||||
args: [2],
|
||||
// should have A timestamp, we don't really care what it is
|
||||
timestamp: state.prefsStorage._journal[0].timestamp
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('helper functions', () => {
|
||||
describe('_moveItemInArray', () => {
|
||||
it('should move item according to movement value', () => {
|
||||
expect(_moveItemInArray([1, 2, 3, 4], 4, -1)).to.eql([1, 2, 4, 3])
|
||||
expect(_moveItemInArray([1, 2, 3, 4], 1, 2)).to.eql([2, 3, 1, 4])
|
||||
})
|
||||
it('should clamp movement to within array', () => {
|
||||
expect(_moveItemInArray([1, 2, 3, 4], 4, -10)).to.eql([4, 1, 2, 3])
|
||||
expect(_moveItemInArray([1, 2, 3, 4], 3, 99)).to.eql([1, 2, 4, 3])
|
||||
})
|
||||
})
|
||||
describe('_getRecentData', () => {
|
||||
it('should handle nulls correctly', () => {
|
||||
expect(_getRecentData(null, null)).to.eql({ recent: null, stale: null, needUpload: true })
|
||||
|
@ -157,6 +207,94 @@ describe('The serverSideStorage module', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('_mergePrefs', () => {
|
||||
it('should prefer recent and apply journal to it', () => {
|
||||
expect(
|
||||
_mergePrefs(
|
||||
// RECENT
|
||||
{
|
||||
simple: { a: 1, b: 0, c: true },
|
||||
_journal: [
|
||||
{ path: 'simple.b', operation: 'set', args: [0], timestamp: 2 },
|
||||
{ path: 'simple.c', operation: 'set', args: [true], timestamp: 4 }
|
||||
]
|
||||
},
|
||||
// STALE
|
||||
{
|
||||
simple: { a: 1, b: 1, c: false },
|
||||
_journal: [
|
||||
{ path: 'simple.a', operation: 'set', args: [1], timestamp: 1 },
|
||||
{ path: 'simple.b', operation: 'set', args: [1], timestamp: 3 }
|
||||
]
|
||||
}
|
||||
)
|
||||
).to.eql({
|
||||
simple: { a: 1, b: 1, c: true },
|
||||
_journal: [
|
||||
{ path: 'simple.a', operation: 'set', args: [1], timestamp: 1 },
|
||||
{ path: 'simple.b', operation: 'set', args: [1], timestamp: 3 },
|
||||
{ path: 'simple.c', operation: 'set', args: [true], timestamp: 4 }
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('should allow setting falsy values', () => {
|
||||
expect(
|
||||
_mergePrefs(
|
||||
// RECENT
|
||||
{
|
||||
simple: { a: 1, b: 0, c: false },
|
||||
_journal: [
|
||||
{ path: 'simple.b', operation: 'set', args: [0], timestamp: 2 },
|
||||
{ path: 'simple.c', operation: 'set', args: [false], timestamp: 4 }
|
||||
]
|
||||
},
|
||||
// STALE
|
||||
{
|
||||
simple: { a: 0, b: 0, c: true },
|
||||
_journal: [
|
||||
{ path: 'simple.a', operation: 'set', args: [0], timestamp: 1 },
|
||||
{ path: 'simple.b', operation: 'set', args: [0], timestamp: 3 }
|
||||
]
|
||||
}
|
||||
)
|
||||
).to.eql({
|
||||
simple: { a: 0, b: 0, c: false },
|
||||
_journal: [
|
||||
{ path: 'simple.a', operation: 'set', args: [0], timestamp: 1 },
|
||||
{ path: 'simple.b', operation: 'set', args: [0], timestamp: 3 },
|
||||
{ path: 'simple.c', operation: 'set', args: [false], timestamp: 4 }
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('should work with strings', () => {
|
||||
expect(
|
||||
_mergePrefs(
|
||||
// RECENT
|
||||
{
|
||||
simple: { a: 'foo' },
|
||||
_journal: [
|
||||
{ path: 'simple.a', operation: 'set', args: ['foo'], timestamp: 2 }
|
||||
]
|
||||
},
|
||||
// STALE
|
||||
{
|
||||
simple: { a: 'bar' },
|
||||
_journal: [
|
||||
{ path: 'simple.a', operation: 'set', args: ['bar'], timestamp: 4 }
|
||||
]
|
||||
}
|
||||
)
|
||||
).to.eql({
|
||||
simple: { a: 'bar' },
|
||||
_journal: [
|
||||
{ path: 'simple.a', operation: 'set', args: ['bar'], timestamp: 4 }
|
||||
]
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('_resetFlags', () => {
|
||||
it('should reset all known flags to 0 when reset flag is set to > 0 and < 9000', () => {
|
||||
const totalFlags = { a: 0, b: 3, reset: 1 }
|
||||
|
|
Loading…
Add table
Reference in a new issue