Merge branch 'navigation-update' into 'develop'

Navigation update + preferences storage (and some minor fixes)

See merge request pleroma/pleroma-fe!1592
This commit is contained in:
tusooa 2022-08-30 00:14:30 +00:00
commit 8b25febe36
57 changed files with 1689 additions and 708 deletions

View File

@ -92,8 +92,12 @@ export default {
isChats () { isChats () {
return this.$route.name === 'chat' || this.$route.name === 'chats' return this.$route.name === 'chat' || this.$route.name === 'chats'
}, },
isListEdit () {
return this.$route.name === 'lists-edit'
},
newPostButtonShown () { newPostButtonShown () {
if (this.isChats) return false if (this.isChats) return false
if (this.isListEdit) return false
return this.$store.getters.mergedConfig.alwaysShowNewPostButton || this.layoutType === 'mobile' return this.$store.getters.mergedConfig.alwaysShowNewPostButton || this.layoutType === 'mobile'
}, },
showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel }, showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel },

View File

@ -117,12 +117,28 @@ h4 {
margin: 0; margin: 0;
} }
.iconLetter {
display: inline-block;
text-align: center;
font-weight: 1000;
}
i[class*=icon-], i[class*=icon-],
.svg-inline--fa { .svg-inline--fa,
.iconLetter {
color: $fallback--icon; color: $fallback--icon;
color: var(--icon, $fallback--icon); color: var(--icon, $fallback--icon);
} }
.button-unstyled:hover,
a:hover {
> i[class*=icon-],
> .svg-inline--fa,
> .iconLetter {
color: var(--text);
}
}
nav { nav {
z-index: var(--ZI_navbar); z-index: var(--ZI_navbar);
color: var(--topBarText); color: var(--topBarText);
@ -765,17 +781,23 @@ option {
} }
.fa-scale-110 { .fa-scale-110 {
&.svg-inline--fa { &.svg-inline--fa,
&.iconLetter {
font-size: 1.1em; font-size: 1.1em;
} }
} }
.fa-old-padding { .fa-old-padding {
&.iconLetter,
&.svg-inline--fa, &-layer { &.svg-inline--fa, &-layer {
padding: 0 0.3em; padding: 0 0.3em;
} }
} }
.veryfaint {
opacity: 0.25;
}
.login-hint { .login-hint {
text-align: center; text-align: center;

View File

@ -36,7 +36,7 @@
<div <div
id="main-scroller" id="main-scroller"
class="column main" class="column main"
:class="{ '-full-height': isChats }" :class="{ '-full-height': isChats || isListEdit }"
> >
<div <div
v-if="!currentUser" v-if="!currentUser"

View File

@ -23,6 +23,7 @@ import RemoteUserResolver from 'components/remote_user_resolver/remote_user_reso
import Lists from 'components/lists/lists.vue' import Lists from 'components/lists/lists.vue'
import ListsTimeline from 'components/lists_timeline/lists_timeline.vue' import ListsTimeline from 'components/lists_timeline/lists_timeline.vue'
import ListsEdit from 'components/lists_edit/lists_edit.vue' import ListsEdit from 'components/lists_edit/lists_edit.vue'
import NavPanel from 'src/components/nav_panel/nav_panel.vue'
export default (store) => { export default (store) => {
const validateAuthenticatedRoute = (to, from, next) => { const validateAuthenticatedRoute = (to, from, next) => {
@ -79,7 +80,9 @@ export default (store) => {
{ name: 'legacy-user-profile', path: '/:name', component: UserProfile }, { name: 'legacy-user-profile', path: '/:name', component: UserProfile },
{ name: 'lists', path: '/lists', component: Lists }, { name: 'lists', path: '/lists', component: Lists },
{ name: 'lists-timeline', path: '/lists/:id', component: ListsTimeline }, { 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: 'lists-new', path: '/lists/new', component: ListsEdit },
{ name: 'edit-navigation', path: '/nav-edit', component: NavPanel, props: () => ({ forceExpand: true, forceEditMode: true }), beforeEnter: validateAuthenticatedRoute }
] ]
if (store.state.instance.pleromaChatMessagesAvailable) { if (store.state.instance.pleromaChatMessagesAvailable) {

View File

@ -1,6 +1,7 @@
import { mapState } from 'vuex' import { mapState } from 'vuex'
import ProgressButton from '../progress_button/progress_button.vue' import ProgressButton from '../progress_button/progress_button.vue'
import Popover from '../popover/popover.vue' import Popover from '../popover/popover.vue'
import UserListMenu from 'src/components/user_list_menu/user_list_menu.vue'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import { import {
faEllipsisV faEllipsisV
@ -19,7 +20,8 @@ const AccountActions = {
}, },
components: { components: {
ProgressButton, ProgressButton,
Popover Popover,
UserListMenu
}, },
methods: { methods: {
showRepeats () { showRepeats () {

View File

@ -28,6 +28,7 @@
class="dropdown-divider" class="dropdown-divider"
/> />
</template> </template>
<UserListMenu :user="user" />
<button <button
v-if="relationship.blocking" v-if="relationship.blocking"
class="btn button-default btn-block dropdown-item" class="btn button-default btn-block dropdown-item"

View File

@ -137,4 +137,8 @@
text-align: right; text-align: right;
} }
} }
.spacer {
width: 1em;
}
} }

View File

@ -61,6 +61,7 @@
:title="$t('nav.administration')" :title="$t('nav.administration')"
/> />
</a> </a>
<span class="spacer" />
<button <button
v-if="currentUser" v-if="currentUser"
class="button-unstyled nav-icon" class="button-unstyled nav-icon"

View File

@ -1,5 +1,4 @@
import ListsCard from '../lists_card/lists_card.vue' import ListsCard from '../lists_card/lists_card.vue'
import ListsNew from '../lists_new/lists_new.vue'
const Lists = { const Lists = {
data () { data () {
@ -8,11 +7,7 @@ const Lists = {
} }
}, },
components: { components: {
ListsCard, ListsCard
ListsNew
},
created () {
this.$store.dispatch('startFetchingLists')
}, },
computed: { computed: {
lists () { lists () {

View File

@ -1,21 +1,15 @@
<template> <template>
<div v-if="isNew"> <div class="Lists panel panel-default">
<ListsNew @cancel="cancelNewList" />
</div>
<div
v-else
class="settings panel panel-default"
>
<div class="panel-heading"> <div class="panel-heading">
<div class="title"> <div class="title">
{{ $t('lists.lists') }} {{ $t('lists.lists') }}
</div> </div>
<button <router-link
class="button-default" :to="{ name: 'lists-new' }"
@click="newList" class="button-default btn new-list-button"
> >
{{ $t("lists.new") }} {{ $t("lists.new") }}
</button> </router-link>
</div> </div>
<div class="panel-body"> <div class="panel-body">
<ListsCard <ListsCard
@ -29,3 +23,11 @@
</template> </template>
<script src="./lists.js"></script> <script src="./lists.js"></script>
<style lang="scss">
.Lists {
.new-list-button {
padding: 0 0.5em;
}
}
</style>

View File

@ -1,7 +1,9 @@
import { mapState, mapGetters } from 'vuex' import { mapState, mapGetters } from 'vuex'
import BasicUserCard from '../basic_user_card/basic_user_card.vue' import BasicUserCard from '../basic_user_card/basic_user_card.vue'
import ListsUserSearch from '../lists_user_search/lists_user_search.vue' import ListsUserSearch from '../lists_user_search/lists_user_search.vue'
import PanelLoading from 'src/components/panel_loading/panel_loading.vue'
import UserAvatar from '../user_avatar/user_avatar.vue' import UserAvatar from '../user_avatar/user_avatar.vue'
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import { import {
faSearch, faSearch,
@ -17,22 +19,33 @@ const ListsNew = {
components: { components: {
BasicUserCard, BasicUserCard,
UserAvatar, UserAvatar,
ListsUserSearch ListsUserSearch,
TabSwitcher,
PanelLoading
}, },
data () { data () {
return { return {
title: '', title: '',
userIds: [], titleDraft: '',
selectedUserIds: [] membersUserIds: [],
removedUserIds: new Set([]), // users we added for members, to undo
searchUserIds: [],
addedUserIds: new Set([]), // users we added from search, to undo
searchLoading: false,
reallyDelete: false
} }
}, },
created () { created () {
this.$store.dispatch('fetchList', { id: this.id }) if (!this.id) return
.then(() => { this.title = this.findListTitle(this.id) }) this.$store.dispatch('fetchList', { listId: this.id })
this.$store.dispatch('fetchListAccounts', { id: this.id })
.then(() => { .then(() => {
this.selectedUserIds = this.findListAccounts(this.id) this.title = this.findListTitle(this.id)
this.selectedUserIds.forEach(userId => { this.titleDraft = this.title
})
this.$store.dispatch('fetchListAccounts', { listId: this.id })
.then(() => {
this.membersUserIds = this.findListAccounts(this.id)
this.membersUserIds.forEach(userId => {
this.$store.dispatch('fetchUserIfMissing', userId) this.$store.dispatch('fetchUserIfMissing', userId)
}) })
}) })
@ -41,11 +54,12 @@ const ListsNew = {
id () { id () {
return this.$route.params.id return this.$route.params.id
}, },
users () { membersUsers () {
return this.userIds.map(userId => this.findUser(userId)) return [...this.membersUserIds, ...this.addedUserIds]
.map(userId => this.findUser(userId)).filter(user => user)
}, },
selectedUsers () { searchUsers () {
return this.selectedUserIds.map(userId => this.findUser(userId)).filter(user => user) return this.searchUserIds.map(userId => this.findUser(userId)).filter(user => user)
}, },
...mapState({ ...mapState({
currentUser: state => state.users.currentUser currentUser: state => state.users.currentUser
@ -56,33 +70,73 @@ const ListsNew = {
onInput () { onInput () {
this.search(this.query) this.search(this.query)
}, },
selectUser (user) { toggleRemoveMember (user) {
if (this.selectedUserIds.includes(user.id)) { if (this.removedUserIds.has(user.id)) {
this.removeUser(user.id) this.id && this.addUser(user)
this.removedUserIds.delete(user.id)
} else { } else {
this.addUser(user) this.id && this.removeUser(user.id)
this.removedUserIds.add(user.id)
} }
}, },
isSelected (user) { toggleAddFromSearch (user) {
return this.selectedUserIds.includes(user.id) if (this.addedUserIds.has(user.id)) {
this.id && this.removeUser(user.id)
this.addedUserIds.delete(user.id)
} else {
this.id && this.addUser(user)
this.addedUserIds.add(user.id)
}
},
isRemoved (user) {
return this.removedUserIds.has(user.id)
},
isAdded (user) {
return this.addedUserIds.has(user.id)
}, },
addUser (user) { addUser (user) {
this.selectedUserIds.push(user.id) this.$store.dispatch('addListAccount', { accountId: this.user.id, listId: this.id })
}, },
removeUser (userId) { removeUser (userId) {
this.selectedUserIds = this.selectedUserIds.filter(id => id !== userId) this.$store.dispatch('removeListAccount', { accountId: this.user.id, listId: this.id })
}, },
onResults (results) { onSearchLoading (results) {
this.userIds = results this.searchLoading = true
}, },
updateList () { onSearchLoadingDone (results) {
this.$store.dispatch('setList', { id: this.id, title: this.title }) this.searchLoading = false
this.$store.dispatch('setListAccounts', { id: this.id, accountIds: this.selectedUserIds }) },
onSearchResults (results) {
this.$router.push({ name: 'lists-timeline', params: { id: this.id } }) this.searchLoading = false
this.searchUserIds = results
},
updateListTitle () {
this.$store.dispatch('setList', { listId: this.id, title: this.titleDraft })
.then(() => {
this.title = this.findListTitle(this.id)
})
},
createList () {
this.$store.dispatch('createList', { title: this.titleDraft })
.then((list) => {
return this
.$store
.dispatch('setListAccounts', { listId: list.id, accountIds: [...this.addedUserIds] })
.then(() => list.id)
})
.then((listId) => {
this.$router.push({ name: 'lists-timeline', params: { id: listId } })
})
.catch((e) => {
this.$store.dispatch('pushGlobalNotice', {
messageKey: 'lists.error',
messageArgs: [e.message],
level: 'error'
})
})
}, },
deleteList () { deleteList () {
this.$store.dispatch('deleteList', { id: this.id }) this.$store.dispatch('deleteList', { listId: this.id })
this.$router.push({ name: 'lists' }) this.$router.push({ name: 'lists' })
} }
} }

View File

@ -1,8 +1,8 @@
<template> <template>
<div class="panel-default panel list-edit"> <div class="panel-default panel ListEdit">
<div <div
ref="header" ref="header"
class="panel-heading" class="panel-heading list-edit-heading"
> >
<button <button
class="button-unstyled go-back-button" class="button-unstyled go-back-button"
@ -13,54 +13,151 @@
icon="chevron-left" icon="chevron-left"
/> />
</button> </button>
</div> <div class="title">
<div class="input-wrap"> <i18n-t
<input v-if="id"
ref="title" keypath="lists.editing_list"
v-model="title" >
:placeholder="$t('lists.title')" <template #listTitle>
> {{ title }}
</div> </template>
<div class="member-list"> </i18n-t>
<div <i18n-t
v-for="user in selectedUsers" v-else
:key="user.id" keypath="lists.creating_list"
class="member"
>
<BasicUserCard
:user="user"
:class="isSelected(user) ? 'selected' : ''"
@click.capture.prevent="selectUser(user)"
/> />
</div> </div>
</div> </div>
<ListsUserSearch @results="onResults" /> <div class="panel-body">
<div class="member-list"> <div class="input-wrap">
<div <label for="list-edit-title">{{ $t('lists.title') }}</label>
v-for="user in users" {{ ' ' }}
:key="user.id" <input
class="member" id="list-edit-title"
> ref="title"
<BasicUserCard v-model="titleDraft"
:user="user" >
:class="isSelected(user) ? 'selected' : ''" <button
@click.capture.prevent="selectUser(user)" v-if="id"
/> class="btn button-default follow-button"
@click="updateListTitle"
>
{{ $t('lists.update_title') }}
</button>
</div> </div>
<tab-switcher
class="list-member-management"
:scrollable-tabs="true"
>
<div
v-if="id || addedUserIds.size > 0"
:label="$t('lists.manage_members')"
class="members-list"
>
<div class="users-list">
<div
v-for="user in membersUsers"
:key="user.id"
class="member"
>
<BasicUserCard
:user="user"
>
<button
class="btn button-default follow-button"
@click="toggleRemoveMember(user)"
>
{{ isRemoved(user) ? $t('general.undo') : $t('lists.remove_from_list') }}
</button>
</BasicUserCard>
</div>
</div>
</div>
<div
class="search-list"
:label="$t('lists.add_members')"
>
<ListsUserSearch
@results="onSearchResults"
@loading="onSearchLoading"
@loadingDone="onSearchLoadingDone"
/>
<div
v-if="searchLoading"
class="loading"
>
<PanelLoading />
</div>
<div
v-else
class="users-list"
>
<div
v-for="user in searchUsers"
:key="user.id"
class="member"
>
<BasicUserCard
:user="user"
>
<span
v-if="membersUserIds.includes(user.id)"
>
{{ $t('lists.is_in_list') }}
</span>
<button
v-if="!membersUserIds.includes(user.id)"
class="btn button-default follow-button"
@click="toggleAddFromSearch(user)"
>
{{ isAdded(user) ? $t('general.undo') : $t('lists.add_to_list') }}
</button>
<button
v-else
class="btn button-default follow-button"
@click="toggleRemoveMember(user)"
>
{{ isRemoved(user) ? $t('general.undo') : $t('lists.remove_from_list') }}
</button>
</BasicUserCard>
</div>
</div>
</div>
</tab-switcher>
</div>
<div class="panel-footer">
<span class="spacer" />
<button
v-if="!id"
class="btn button-default footer-button"
@click="createList"
>
{{ $t('lists.create') }}
</button>
<button
v-else-if="!reallyDelete"
class="btn button-default footer-button"
@click="reallyDelete = true"
>
{{ $t('lists.delete') }}
</button>
<template v-else>
{{ $t('lists.really_delete') }}
<button
class="btn button-default footer-button"
@click="deleteList"
>
{{ $t('general.yes') }}
</button>
<button
class="btn button-default footer-button"
@click="reallyDelete = false"
>
{{ $t('general.no') }}
</button>
</template>
</div> </div>
<button
:disabled="title && title.length === 0"
class="btn button-default"
@click="updateList"
>
{{ $t('lists.save') }}
</button>
<button
class="btn button-default"
@click="deleteList"
>
{{ $t('lists.delete') }}
</button>
</div> </div>
</template> </template>
@ -69,28 +166,43 @@
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import '../../_variables.scss';
.list-edit { .ListEdit {
.input-wrap { --panel-body-padding: 0.5em;
display: flex;
margin: 0.7em 0.5em 0.7em 0.5em;
input { height: calc(100vh - var(--navbar-height));
width: 100%; overflow: hidden;
} display: flex;
flex-direction: column;
.list-edit-heading {
grid-template-columns: auto minmax(50%, 1fr);
}
.panel-body {
display: flex;
flex: 1;
flex-direction: column;
overflow: hidden;
}
.list-member-management {
flex: 1 0 auto;
} }
.search-icon { .search-icon {
margin-right: 0.3em; margin-right: 0.3em;
} }
.member-list { .users-list {
padding-bottom: 0.7rem; padding-bottom: 0.7rem;
overflow-y: auto;
} }
.basic-user-card:hover, & .search-list,
.basic-user-card.selected { & .members-list {
cursor: pointer; overflow: hidden;
background-color: var(--selectedPost, $fallback--lightBg); flex-direction: column;
min-height: 0;
} }
.go-back-button { .go-back-button {
@ -102,7 +214,15 @@
} }
.btn { .btn {
margin: 0.5em; margin: 0 0.5em;
}
.panel-footer {
grid-template-columns: minmax(10%, 1fr);
.footer-button {
min-width: 9em;
}
} }
} }
</style> </style>

View File

@ -1,28 +1,17 @@
import { mapState } from 'vuex' import { mapState } from 'vuex'
import { library } from '@fortawesome/fontawesome-svg-core' import NavigationEntry from 'src/components/navigation/navigation_entry.vue'
import { import { getListEntries } from 'src/components/navigation/filter.js'
faUsers,
faGlobe,
faBookmark,
faEnvelope,
faHome
} from '@fortawesome/free-solid-svg-icons'
library.add( export const ListsMenuContent = {
faUsers, props: [
faGlobe, 'showPin'
faBookmark, ],
faEnvelope, components: {
faHome NavigationEntry
)
const ListsMenuContent = {
created () {
this.$store.dispatch('startFetchingLists')
}, },
computed: { computed: {
...mapState({ ...mapState({
lists: state => state.lists.allLists, lists: getListEntries,
currentUser: state => state.users.currentUser, currentUser: state => state.users.currentUser,
privateMode: state => state.instance.private, privateMode: state => state.instance.private,
federating: state => state.instance.federating federating: state => state.instance.federating

View File

@ -1,16 +1,11 @@
<template> <template>
<ul> <ul>
<li <NavigationEntry
v-for="list in lists.slice().reverse()" v-for="item in lists"
:key="list.id" :key="item.name"
> :show-pin="showPin"
<router-link :item="item"
class="menu-item" />
:to="{ name: 'lists-timeline', params: { id: list.id } }"
>
{{ list.title }}
</router-link>
</li>
</ul> </ul>
</template> </template>

View File

@ -1,79 +0,0 @@
import { mapState, mapGetters } from 'vuex'
import BasicUserCard from '../basic_user_card/basic_user_card.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import ListsUserSearch from '../lists_user_search/lists_user_search.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faSearch,
faChevronLeft
} from '@fortawesome/free-solid-svg-icons'
library.add(
faSearch,
faChevronLeft
)
const ListsNew = {
components: {
BasicUserCard,
UserAvatar,
ListsUserSearch
},
data () {
return {
title: '',
userIds: [],
selectedUserIds: []
}
},
computed: {
users () {
return this.userIds.map(userId => this.findUser(userId))
},
selectedUsers () {
return this.selectedUserIds.map(userId => this.findUser(userId))
},
...mapState({
currentUser: state => state.users.currentUser
}),
...mapGetters(['findUser'])
},
methods: {
goBack () {
this.$emit('cancel')
},
onInput () {
this.search(this.query)
},
selectUser (user) {
if (this.selectedUserIds.includes(user.id)) {
this.removeUser(user.id)
} else {
this.addUser(user)
}
},
isSelected (user) {
return this.selectedUserIds.includes(user.id)
},
addUser (user) {
this.selectedUserIds.push(user.id)
},
removeUser (userId) {
this.selectedUserIds = this.selectedUserIds.filter(id => id !== userId)
},
onResults (results) {
this.userIds = results
},
createList () {
// the API has two different endpoints for "creating a list with a name"
// and "updating the accounts on the list".
this.$store.dispatch('createList', { title: this.title })
.then((list) => {
this.$store.dispatch('setListAccounts', { id: list.id, accountIds: this.selectedUserIds })
this.$router.push({ name: 'lists-timeline', params: { id: list.id } })
})
}
}
}
export default ListsNew

View File

@ -1,95 +0,0 @@
<template>
<div class="panel-default panel list-new">
<div
ref="header"
class="panel-heading"
>
<button
class="button-unstyled go-back-button"
@click="goBack"
>
<FAIcon
size="lg"
icon="chevron-left"
/>
</button>
</div>
<div class="input-wrap">
<input
ref="title"
v-model="title"
:placeholder="$t('lists.title')"
>
</div>
<div class="member-list">
<div
v-for="user in selectedUsers"
:key="user.id"
class="member"
>
<BasicUserCard
:user="user"
:class="isSelected(user) ? 'selected' : ''"
@click.capture.prevent="selectUser(user)"
/>
</div>
</div>
<ListsUserSearch
@results="onResults"
/>
<div
v-for="user in users"
:key="user.id"
class="member"
>
<BasicUserCard
:user="user"
:class="isSelected(user) ? 'selected' : ''"
@click.capture.prevent="selectUser(user)"
/>
</div>
<button
:disabled="title && title.length === 0"
class="btn button-default"
@click="createList"
>
{{ $t('lists.create') }}
</button>
</div>
</template>
<script src="./lists_new.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.list-new {
.search-icon {
margin-right: 0.3em;
}
.member-list {
padding-bottom: 0.7rem;
}
.basic-user-card:hover,
.basic-user-card.selected {
cursor: pointer;
background-color: var(--selectedPost, $fallback--lightBg);
}
.go-back-button {
text-align: center;
line-height: 1;
height: 100%;
align-self: start;
width: var(--__panel-heading-height-inner);
}
.btn {
margin: 0.5em;
}
}
</style>

View File

@ -17,14 +17,14 @@ const ListsTimeline = {
this.listId = route.params.id this.listId = route.params.id
this.$store.dispatch('stopFetchingTimeline', 'list') this.$store.dispatch('stopFetchingTimeline', 'list')
this.$store.commit('clearTimeline', { timeline: 'list' }) this.$store.commit('clearTimeline', { timeline: 'list' })
this.$store.dispatch('fetchList', { id: this.listId }) this.$store.dispatch('fetchList', { listId: this.listId })
this.$store.dispatch('startFetchingTimeline', { timeline: 'list', listId: this.listId }) this.$store.dispatch('startFetchingTimeline', { timeline: 'list', listId: this.listId })
} }
} }
}, },
created () { created () {
this.listId = this.$route.params.id this.listId = this.$route.params.id
this.$store.dispatch('fetchList', { id: this.listId }) this.$store.dispatch('fetchList', { listId: this.listId })
this.$store.dispatch('startFetchingTimeline', { timeline: 'list', listId: this.listId }) this.$store.dispatch('startFetchingTimeline', { timeline: 'list', listId: this.listId })
}, },
unmounted () { unmounted () {

View File

@ -15,6 +15,7 @@ const ListsUserSearch = {
components: { components: {
Checkbox Checkbox
}, },
emits: ['loading', 'loadingDone', 'results'],
data () { data () {
return { return {
loading: false, loading: false,
@ -33,12 +34,16 @@ const ListsUserSearch = {
} }
this.loading = true this.loading = true
this.$emit('loading')
this.userIds = [] this.userIds = []
this.$store.dispatch('search', { q: query, resolve: true, type: 'accounts', following: this.followingOnly }) this.$store.dispatch('search', { q: query, resolve: true, type: 'accounts', following: this.followingOnly })
.then(data => { .then(data => {
this.loading = false
this.$emit('results', data.accounts.map(a => a.id)) this.$emit('results', data.accounts.map(a => a.id))
}) })
.finally(() => {
this.loading = false
this.$emit('loadingDone')
})
} }
} }
} }

View File

@ -1,5 +1,5 @@
<template> <template>
<div> <div class="ListsUserSearch">
<div class="input-wrap"> <div class="input-wrap">
<div class="input-search"> <div class="input-search">
<FAIcon <FAIcon
@ -29,17 +29,19 @@
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import '../../_variables.scss';
.input-wrap { .ListsUserSearch {
display: flex; .input-wrap {
margin: 0.7em 0.5em 0.7em 0.5em; display: flex;
margin: 0.7em 0.5em 0.7em 0.5em;
input { input {
width: 100%; width: 100%;
}
}
.search-icon {
margin-right: 0.3em;
} }
} }
.search-icon {
margin-right: 0.3em;
}
</style> </style>

View File

@ -2,6 +2,7 @@ import SideDrawer from '../side_drawer/side_drawer.vue'
import Notifications from '../notifications/notifications.vue' import Notifications from '../notifications/notifications.vue'
import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils' import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils'
import GestureService from '../../services/gesture_service/gesture_service' import GestureService from '../../services/gesture_service/gesture_service'
import NavigationPins from 'src/components/navigation/navigation_pins.vue'
import { mapGetters } from 'vuex' import { mapGetters } from 'vuex'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import { import {
@ -19,7 +20,8 @@ library.add(
const MobileNav = { const MobileNav = {
components: { components: {
SideDrawer, SideDrawer,
Notifications Notifications,
NavigationPins
}, },
data: () => ({ data: () => ({
notificationsCloseGesture: undefined, notificationsCloseGesture: undefined,
@ -47,7 +49,10 @@ const MobileNav = {
isChat () { isChat () {
return this.$route.name === 'chat' return this.$route.name === 'chat'
}, },
...mapGetters(['unreadChatCount']) ...mapGetters(['unreadChatCount']),
chatsPinned () {
return new Set(this.$store.state.serverSideStorage.prefsStorage.collections.pinnedNavItems).has('chats')
}
}, },
methods: { methods: {
toggleMobileSidebar () { toggleMobileSidebar () {

View File

@ -17,20 +17,12 @@
icon="bars" icon="bars"
/> />
<div <div
v-if="unreadChatCount" v-if="unreadChatCount && !chatsPinned"
class="alert-dot" class="alert-dot"
/> />
</button> </button>
<router-link <NavigationPins class="pins" />
v-if="!hideSitename" </div> <div class="item right">
class="site-name"
:to="{ name: 'root' }"
active-class="home"
>
{{ sitename }}
</router-link>
</div>
<div class="item right">
<button <button
v-if="currentUser" v-if="currentUser"
class="button-unstyled mobile-nav-button" class="button-unstyled mobile-nav-button"
@ -94,6 +86,7 @@
grid-template-columns: 2fr auto; grid-template-columns: 2fr auto;
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
a { a {
color: var(--topBarLink, $fallback--link); color: var(--topBarLink, $fallback--link);
} }
@ -178,13 +171,20 @@
} }
} }
.pins {
flex: 1;
.pinned-item {
flex-grow: 1;
}
}
.mobile-notifications { .mobile-notifications {
margin-top: 50px; margin-top: 50px;
width: 100vw; width: 100vw;
height: calc(100vh - var(--navbar-height)); height: calc(100vh - var(--navbar-height));
overflow-x: hidden; overflow-x: hidden;
overflow-y: scroll; overflow-y: scroll;
color: $fallback--text; color: $fallback--text;
color: var(--text, $fallback--text); color: var(--text, $fallback--text);
background-color: $fallback--bg; background-color: $fallback--bg;
@ -194,14 +194,17 @@
padding: 0; padding: 0;
border-radius: 0; border-radius: 0;
box-shadow: none; box-shadow: none;
.panel { .panel {
border-radius: 0; border-radius: 0;
margin: 0; margin: 0;
box-shadow: none; box-shadow: none;
} }
.panel:after {
.panel::after {
border-radius: 0; border-radius: 0;
} }
.panel .panel-heading { .panel .panel-heading {
border-radius: 0; border-radius: 0;
box-shadow: none; box-shadow: none;

View File

@ -10,7 +10,8 @@ library.add(
const HIDDEN_FOR_PAGES = new Set([ const HIDDEN_FOR_PAGES = new Set([
'chats', 'chats',
'chat' 'chat',
'lists-edit'
]) ])
const MobilePostStatusButton = { const MobilePostStatusButton = {

View File

@ -1,6 +1,10 @@
import TimelineMenuContent from '../timeline_menu/timeline_menu_content.vue' import ListsMenuContent from 'src/components/lists_menu/lists_menu_content.vue'
import ListsMenuContent from '../lists_menu/lists_menu_content.vue'
import { mapState, mapGetters } from 'vuex' 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 Checkbox from 'src/components/checkbox/checkbox.vue'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import { import {
@ -30,21 +34,23 @@ library.add(
faStream, faStream,
faList faList
) )
const NavPanel = { const NavPanel = {
props: ['forceExpand', 'forceEditMode'],
created () { created () {
if (this.currentUser && this.currentUser.locked) {
this.$store.dispatch('startFetchingFollowRequests')
}
}, },
components: { components: {
TimelineMenuContent, ListsMenuContent,
ListsMenuContent NavigationEntry,
NavigationPins,
Checkbox
}, },
data () { data () {
return { return {
editMode: false,
showTimelines: false, 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: { methods: {
@ -53,19 +59,62 @@ const NavPanel = {
}, },
toggleLists () { toggleLists () {
this.showLists = !this.showLists this.showLists = !this.showLists
},
toggleEditMode () {
this.editMode = !this.editMode
},
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: { computed: {
listsNavigation () {
return this.$store.getters.mergedConfig.listsNavigation
},
...mapState({ ...mapState({
currentUser: state => state.users.currentUser, currentUser: state => state.users.currentUser,
followRequestCount: state => state.api.followRequests.length, followRequestCount: state => state.api.followRequests.length,
privateMode: state => state.instance.private, privateMode: state => state.instance.private,
federating: state => state.instance.federating, 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']) ...mapGetters(['unreadChatCount'])
} }
} }

View File

@ -1,135 +1,99 @@
<template> <template>
<div class="NavPanel"> <div class="NavPanel">
<div class="panel panel-default"> <div class="panel panel-default">
<ul> <div
<li v-if="currentUser || !privateMode"> v-if="!forceExpand"
<button class="panel-heading nav-panel-heading"
class="button-unstyled menu-item" >
@click="toggleTimelines" <NavigationPins :limit="6" />
> <div class="spacer" />
<FAIcon <button
fixed-width class="button-unstyled"
class="fa-scale-110" @click="toggleCollapse"
icon="stream" >
/>{{ $t("nav.timelines") }} <FAIcon
<FAIcon class="timelines-chevron"
class="timelines-chevron" fixed-width
fixed-width :icon="collapsed ? 'chevron-down' : 'chevron-up'"
:icon="showTimelines ? 'chevron-up' : 'chevron-down'" />
</button>
</div>
<ul
v-if="!collapsed || forceExpand"
class="panel-body"
>
<NavigationEntry
v-if="currentUser || !privateMode"
:show-pin="false"
:item="{ icon: 'stream', label: 'nav.timelines' }"
:aria-expanded="showTimelines ? 'true' : 'false'"
@click="toggleTimelines"
>
<FAIcon
class="timelines-chevron"
fixed-width
:icon="showTimelines ? 'chevron-up' : 'chevron-down'"
/>
</NavigationEntry>
<div
v-show="showTimelines"
class="timelines-background"
>
<div class="timelines">
<NavigationEntry
v-for="item in timelinesItems"
:key="item.name"
:show-pin="editMode || forceEditMode"
:item="item"
/> />
</button>
<div
v-show="showTimelines"
class="timelines-background"
>
<TimelineMenuContent class="timelines" />
</div> </div>
</li> </div>
<li v-if="currentUser && listsNavigation"> <NavigationEntry
<button v-if="currentUser"
class="button-unstyled menu-item" :show-pin="false"
@click="toggleLists" :item="{ icon: 'list', label: 'nav.lists' }"
> :aria-expanded="showLists ? 'true' : 'false'"
<router-link @click="toggleLists"
:to="{ name: 'lists' }" >
@click.stop
>
<FAIcon
fixed-width
class="fa-scale-110"
icon="list"
/>{{ $t("nav.lists") }}
</router-link>
<FAIcon
class="timelines-chevron"
fixed-width
:icon="showLists ? 'chevron-up' : 'chevron-down'"
/>
</button>
<div
v-show="showLists"
class="timelines-background"
>
<ListsMenuContent class="timelines" />
</div>
</li>
<li v-if="currentUser && !listsNavigation">
<router-link <router-link
:title="$t('lists.manage_lists')"
class="extra-button"
:to="{ name: 'lists' }" :to="{ name: 'lists' }"
@click.stop @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 <FAIcon
class="extra-button"
fixed-width fixed-width
class="fa-scale-110" icon="wrench"
icon="bell" />
/>{{ $t("nav.interactions") }}
</router-link> </router-link>
</li> <FAIcon
<li v-if="currentUser && pleromaChatMessagesAvailable"> class="timelines-chevron"
<router-link fixed-width
class="menu-item" :icon="showLists ? 'chevron-up' : 'chevron-down'"
:to="{ name: 'chats', params: { username: currentUser.screen_name } }" />
> </NavigationEntry>
<div <div
v-if="unreadChatCount" v-show="showLists"
class="badge badge-notification" class="timelines-background"
> >
{{ unreadChatCount }} <ListsMenuContent
</div> :show-pin="editMode || forceEditMode"
<FAIcon class="timelines"
fixed-width />
class="fa-scale-110" </div>
icon="comments" <NavigationEntry
/>{{ $t("nav.chats") }} v-for="item in rootItems"
</router-link> :key="item.name"
</li> :show-pin="editMode || forceEditMode"
<li v-if="currentUser && currentUser.locked"> :item="item"
<router-link />
class="menu-item" <NavigationEntry
:to="{ name: 'friend-requests' }" v-if="!forceEditMode && currentUser"
> :show-pin="false"
<FAIcon :item="{ label: editMode ? $t('nav.edit_finish') : $t('nav.edit_pinned'), icon: editMode ? 'check' : 'wrench' }"
fixed-width @click="toggleEditMode"
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>
</ul> </ul>
</div> </div>
</div> </div>
@ -180,54 +144,23 @@
border: none; border: none;
} }
.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;
}
}
}
.timelines-chevron { .timelines-chevron {
margin-left: 0.8em; margin-left: 0.8em;
margin-right: 0.8em;
font-size: 1.1em; font-size: 1.1em;
} }
.menu-item {
.timelines-chevron {
margin-right: 0;
}
}
.timelines-background { .timelines-background {
padding: 0 0 0 0.6em; padding: 0 0 0 0.6em;
background-color: $fallback--lightBg; background-color: $fallback--lightBg;
background-color: var(--selectedMenu, $fallback--lightBg); background-color: var(--selectedMenu, $fallback--lightBg);
border-top: 1px solid; border-bottom: 1px solid;
border-color: $fallback--border; border-color: $fallback--border;
border-color: var(--border, $fallback--border); border-color: var(--border, $fallback--border);
} }
@ -237,14 +170,9 @@
background-color: var(--bg, $fallback--bg); background-color: var(--bg, $fallback--bg);
} }
.fa-scale-110 { .nav-panel-heading {
margin-right: 0.8em; // breaks without a unit
} --panel-heading-height-padding: 0em;
.badge {
position: absolute;
right: 0.6rem;
top: 1.25em;
} }
} }
</style> </style>

View File

@ -0,0 +1,18 @@
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
})
}
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]
}))

View File

@ -0,0 +1,75 @@
export const USERNAME_ROUTES = new Set([
'bookmarks',
'dms',
'interactions',
'notifications',
'chat',
'chats',
'user-profile'
])
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'
},
favorites: {
routeObject: { name: 'user-profile', query: { tab: 'favorites' } },
icon: 'star',
label: 'user_card.favorites'
},
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'
}
}

View File

@ -0,0 +1,47 @@
import { mapState } from 'vuex'
import { USERNAME_ROUTES } from 'src/components/navigation/navigation.js'
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: {
routeTo () {
if (!this.item.route && !this.item.routeObject) return null
let route
if (this.item.routeObject) {
route = this.item.routeObject
} else {
route = { name: (this.item.anon || this.currentUser) ? this.item.route : this.item.anonRoute }
}
if (USERNAME_ROUTES.has(route.name)) {
route.params = { username: this.currentUser.screen_name, name: this.currentUser.screen_name }
}
return route
},
getters () {
return this.$store.getters
},
...mapState({
currentUser: state => state.users.currentUser,
pinnedItems: state => new Set(state.serverSideStorage.prefsStorage.collections.pinnedNavItems)
})
}
}
export default NavigationEntry

View File

@ -0,0 +1,120 @@
<template>
<li class="NavigationEntry">
<component
:is="routeTo ? 'router-link' : 'button'"
class="menu-item button-unstyled"
:to="routeTo"
>
<span>
<FAIcon
v-if="item.icon"
fixed-width
class="fa-scale-110 menu-icon"
:icon="item.icon"
/>
</span>
<span
v-if="item.iconLetter"
class="icon iconLetter fa-scale-110 menu-icon"
>{{ item.iconLetter }}
</span>
<span class="label">
{{ item.labelRaw || $t(item.label) }}
</span>
<slot />
<div
v-if="item.badgeGetter && getters[item.badgeGetter]"
class="badge badge-notification"
>
{{ getters[item.badgeGetter] }}
</div>
<button
v-if="showPin && currentUser"
type="button"
class="button-unstyled extra-button"
:title="$t(isPinned ? 'general.unpin' : 'general.pin' )"
:aria-pressed="!!isPinned"
@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"
/>
</button>
</component>
</li>
</template>
<script src="./navigation_entry.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.NavigationEntry {
.label {
flex: 1;
}
.menu-icon {
margin-right: 0.8em;
}
.extra-button {
width: 3em;
text-align: center;
&:last-child {
margin-right: -0.8em;
}
}
.menu-item {
display: flex;
box-sizing: border-box;
align-items: baseline;
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);
.menu-icon {
--icon: var(--text, $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);
.menu-icon {
--icon: var(--text, $fallback--icon);
}
&:hover {
text-decoration: underline;
}
}
}
}
</style>

View File

@ -0,0 +1,88 @@
import { mapState } from 'vuex'
import { TIMELINES, ROOT_ITEMS, USERNAME_ROUTES } from 'src/components/navigation/navigation.js'
import { getListEntries, 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'],
methods: {
getRouteTo (item) {
if (item.routeObject) {
return item.routeObject
}
const route = { name: (item.anon || this.currentUser) ? item.route : item.anonRoute }
if (USERNAME_ROUTES.has(route.name)) {
route.params = { username: this.currentUser.screen_name }
}
return route
}
},
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

View File

@ -0,0 +1,76 @@
<template>
<span class="NavigationPins">
<router-link
v-for="item in pinnedList"
:key="item.name"
class="pinned-item"
:to="getRouteTo(item)"
:title="item.labelRaw || $t(item.label)"
>
<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;
flex-wrap: wrap;
overflow: hidden;
height: 100%;
.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: 1 0 3em;
min-width: 2em;
text-align: center;
overflow: visible;
box-sizing: border-box;
height: 100%;
& .svg-inline--fa,
& .iconLetter {
margin: 0;
}
&.router-link-active {
color: $fallback--text;
color: var(--selectedMenuText, $fallback--text);
border-bottom: 4px solid;
& .svg-inline--fa,
& .iconLetter {
color: inherit;
}
}
}
}
</style>

View File

@ -4,7 +4,7 @@ const Popover = {
// Action to trigger popover: either 'hover' or 'click' // Action to trigger popover: either 'hover' or 'click'
trigger: String, trigger: String,
// Either 'top' or 'bottom' // 'top', 'bottom', 'left', 'right'
placement: String, placement: String,
// Takes object with properties 'x' and 'y', values of these can be // Takes object with properties 'x' and 'y', values of these can be
@ -84,6 +84,8 @@ const Popover = {
const anchorStyle = getComputedStyle(anchorEl) const anchorStyle = getComputedStyle(anchorEl)
const topPadding = parseFloat(anchorStyle.paddingTop) const topPadding = parseFloat(anchorStyle.paddingTop)
const bottomPadding = parseFloat(anchorStyle.paddingBottom) const bottomPadding = parseFloat(anchorStyle.paddingBottom)
const rightPadding = parseFloat(anchorStyle.paddingRight)
const leftPadding = parseFloat(anchorStyle.paddingLeft)
// Screen position of the origin point for popover = center of the anchor // Screen position of the origin point for popover = center of the anchor
const origin = { const origin = {
@ -170,7 +172,7 @@ const Popover = {
if (overlayCenter) { if (overlayCenter) {
translateX = origin.x + horizOffset translateX = origin.x + horizOffset
translateY = origin.y + vertOffset translateY = origin.y + vertOffset
} else { } else if (this.placement !== 'right' && this.placement !== 'left') {
// Default to whatever user wished with placement prop // Default to whatever user wished with placement prop
let usingTop = this.placement !== 'bottom' let usingTop = this.placement !== 'bottom'
@ -189,6 +191,25 @@ const Popover = {
const xOffset = (this.offset && this.offset.x) || 0 const xOffset = (this.offset && this.offset.x) || 0
translateX = origin.x + horizOffset + xOffset translateX = origin.x + horizOffset + xOffset
} else {
// Default to whatever user wished with placement prop
let usingRight = this.placement !== 'left'
// Handle special cases, first force to displaying on top if there's not space on bottom,
// regardless of what placement value was. Then check if there's not space on top, and
// force to bottom, again regardless of what placement value was.
const rightBoundary = origin.x - anchorWidth * 0.5 + (this.removePadding ? rightPadding : 0)
const leftBoundary = origin.x + anchorWidth * 0.5 - (this.removePadding ? leftPadding : 0)
if (leftBoundary + content.offsetWidth > xBounds.max) usingRight = true
if (rightBoundary - content.offsetWidth < xBounds.min) usingRight = false
const xOffset = (this.offset && this.offset.x) || 0
translateX = usingRight
? rightBoundary - xOffset - content.offsetWidth
: leftBoundary + xOffset
const yOffset = (this.offset && this.offset.y) || 0
translateY = origin.y + vertOffset + yOffset
} }
this.styles = { this.styles = {

View File

@ -126,6 +126,13 @@
} }
} }
&.-has-submenu {
.chevron-icon {
margin-right: 0.25rem;
margin-left: 2rem;
}
}
&:active, &:hover { &:active, &:hover {
background-color: $fallback--lightBg; background-color: $fallback--lightBg;
background-color: var(--selectedMenuPopover, $fallback--lightBg); background-color: var(--selectedMenuPopover, $fallback--lightBg);

View File

@ -47,6 +47,8 @@
class="cancel-icon fa-scale-110 fa-old-padding" class="cancel-icon fa-scale-110 fa-old-padding"
/> />
</button> </button>
<span class="spacer" />
<span class="spacer" />
</template> </template>
</div> </div>
</template> </template>

View File

@ -101,11 +101,6 @@
{{ $t('settings.hide_shoutbox') }} {{ $t('settings.hide_shoutbox') }}
</BooleanSetting> </BooleanSetting>
</li> </li>
<li>
<BooleanSetting path="listsNavigation">
{{ $t('settings.lists_navigation') }}
</BooleanSetting>
</li>
<li> <li>
<h3>{{ $t('settings.columns') }}</h3> <h3>{{ $t('settings.columns') }}</h3>
</li> </li>

View File

@ -2,6 +2,7 @@ import { mapState, mapGetters } from 'vuex'
import UserCard from '../user_card/user_card.vue' import UserCard from '../user_card/user_card.vue'
import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils' import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils'
import GestureService from '../../services/gesture_service/gesture_service' import GestureService from '../../services/gesture_service/gesture_service'
import { USERNAME_ROUTES } from 'src/components/navigation/navigation.js'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import { import {
faSignInAlt, faSignInAlt,
@ -15,6 +16,7 @@ import {
faTachometerAlt, faTachometerAlt,
faCog, faCog,
faInfoCircle, faInfoCircle,
faCompass,
faList faList
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
@ -30,6 +32,7 @@ library.add(
faTachometerAlt, faTachometerAlt,
faCog, faCog,
faInfoCircle, faInfoCircle,
faCompass,
faList faList
) )
@ -80,10 +83,16 @@ const SideDrawer = {
return this.$store.state.instance.federating return this.$store.state.instance.federating
}, },
timelinesRoute () { timelinesRoute () {
let name
if (this.$store.state.interface.lastTimeline) { if (this.$store.state.interface.lastTimeline) {
return this.$store.state.interface.lastTimeline name = this.$store.state.interface.lastTimeline
}
name = this.currentUser ? 'friends' : 'public-timeline'
if (USERNAME_ROUTES.has(name)) {
return { name, params: { username: this.currentUser.screen_name } }
} else {
return { name }
} }
return this.currentUser ? 'friends' : 'public-timeline'
}, },
...mapState({ ...mapState({
pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable

View File

@ -47,7 +47,7 @@
v-if="currentUser || !privateMode" v-if="currentUser || !privateMode"
@click="toggleDrawer" @click="toggleDrawer"
> >
<router-link :to="{ name: timelinesRoute }"> <router-link :to="timelinesRoute">
<FAIcon <FAIcon
fixed-width fixed-width
class="fa-scale-110 fa-old-padding" class="fa-scale-110 fa-old-padding"
@ -191,6 +191,18 @@
/> {{ $t("nav.administration") }} /> {{ $t("nav.administration") }}
</a> </a>
</li> </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 <li
v-if="currentUser" v-if="currentUser"
@click="toggleDrawer" @click="toggleDrawer"

View File

@ -17,6 +17,7 @@
overflow-x: auto; overflow-x: auto;
padding-top: 5px; padding-top: 5px;
flex-direction: row; flex-direction: row;
flex: 0 0 auto;
&::after, &::before { &::after, &::before {
content: ''; content: '';

View File

@ -1,7 +1,10 @@
<template> <template>
<div :class="['Timeline', classes.root]"> <div :class="['Timeline', classes.root]">
<div :class="classes.header"> <div :class="classes.header">
<TimelineMenu v-if="!embedded" /> <TimelineMenu
v-if="!embedded"
:timeline-name="timelineName"
/>
<button <button
v-if="showLoadButton" v-if="showLoadButton"
class="button-default loadmore-button" class="button-default loadmore-button"

View File

@ -1,6 +1,8 @@
import Popover from '../popover/popover.vue' 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 { library } from '@fortawesome/fontawesome-svg-core'
import { TIMELINES } from 'src/components/navigation/navigation.js'
import { import {
faChevronDown faChevronDown
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
@ -22,11 +24,13 @@ export const timelineNames = () => {
const TimelineMenu = { const TimelineMenu = {
components: { components: {
Popover, Popover,
TimelineMenuContent NavigationEntry,
ListsMenuContent
}, },
data () { data () {
return { return {
isOpen: false isOpen: false,
timelinesList: Object.entries(TIMELINES).map(([k, v]) => ({ ...v, name: k }))
} }
}, },
created () { created () {
@ -34,6 +38,12 @@ const TimelineMenu = {
this.$store.dispatch('setLastTimeline', this.$route.name) this.$store.dispatch('setLastTimeline', this.$route.name)
} }
}, },
computed: {
useListsMenu () {
const route = this.$route.name
return route === 'lists-timeline'
}
},
methods: { methods: {
openMenu () { openMenu () {
// $nextTick is too fast, animation won't play back but // $nextTick is too fast, animation won't play back but

View File

@ -10,7 +10,19 @@
@close="() => isOpen = false" @close="() => isOpen = false"
> >
<template #content> <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>
<template #trigger> <template #trigger>
<span class="button-unstyled title timeline-menu-title"> <span class="button-unstyled title timeline-menu-title">
@ -138,8 +150,7 @@
background-color: $fallback--lightBg; background-color: $fallback--lightBg;
background-color: var(--selectedMenu, $fallback--lightBg); background-color: var(--selectedMenu, $fallback--lightBg);
color: $fallback--text; color: $fallback--text;
color: var(--selectedMenuText, $fallback--text); color: var(--selectedMenuText, $fallback--text); --faint: var(--selectedMenuFaintText, $fallback--faint);
--faint: var(--selectedMenuFaintText, $fallback--faint);
--faintLink: var(--selectedMenuFaintLink, $fallback--faint); --faintLink: var(--selectedMenuFaintLink, $fallback--faint);
--lightText: var(--selectedMenuLightText, $fallback--lightText); --lightText: var(--selectedMenuLightText, $fallback--lightText);
--icon: var(--selectedMenuIcon, $fallback--icon); --icon: var(--selectedMenuIcon, $fallback--icon);

View File

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

View File

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

View File

@ -38,7 +38,7 @@ const UpdateNotification = {
return !this.$store.state.instance.disableUpdateNotification && return !this.$store.state.instance.disableUpdateNotification &&
this.$store.state.users.currentUser && this.$store.state.users.currentUser &&
this.$store.state.serverSideStorage.flagStorage.updateCounter < CURRENT_UPDATE_COUNTER && this.$store.state.serverSideStorage.flagStorage.updateCounter < CURRENT_UPDATE_COUNTER &&
!this.$store.state.serverSideStorage.flagStorage.dontShowUpdateNotifs !this.$store.state.serverSideStorage.prefsStorage.simple.dontShowUpdateNotifs
} }
}, },
methods: { methods: {
@ -48,7 +48,7 @@ const UpdateNotification = {
neverShowAgain () { neverShowAgain () {
this.toggleShow() this.toggleShow()
this.$store.commit('setFlag', { flag: 'updateCounter', value: CURRENT_UPDATE_COUNTER }) 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') this.$store.dispatch('pushServerSideStorage')
}, },
dismiss () { dismiss () {

View File

@ -60,7 +60,7 @@
<template #linkToArtist> <template #linkToArtist>
<a <a
target="_blank" target="_blank"
href="https://post.ebin.club/pipivovott" href="https://post.ebin.club/users/pipivovott"
>pipivovott</a> >pipivovott</a>
</template> </template>
</i18n-t> </i18n-t>

View File

@ -0,0 +1,93 @@
import { library } from '@fortawesome/fontawesome-svg-core'
import { faChevronRight } from '@fortawesome/free-solid-svg-icons'
import { mapState } from 'vuex'
import DialogModal from '../dialog_modal/dialog_modal.vue'
import Popover from '../popover/popover.vue'
library.add(faChevronRight)
const UserListMenu = {
props: [
'user'
],
data () {
return {}
},
components: {
DialogModal,
Popover
},
created () {
this.$store.dispatch('fetchUserInLists', this.user.id)
},
computed: {
...mapState({
allLists: state => state.lists.allLists
}),
inListsSet () {
return new Set(this.user.inLists.map(x => x.id))
},
lists () {
if (!this.user.inLists) return []
return this.allLists.map(list => ({
...list,
inList: this.inListsSet.has(list.id)
}))
}
},
methods: {
toggleList (listId) {
if (this.inListsSet.has(listId)) {
this.$store.dispatch('removeListAccount', { accountId: this.user.id, listId }).then((response) => {
if (!response.ok) { return }
this.$store.dispatch('fetchUserInLists', this.user.id)
})
} else {
this.$store.dispatch('addListAccount', { accountId: this.user.id, listId }).then((response) => {
if (!response.ok) { return }
this.$store.dispatch('fetchUserInLists', this.user.id)
})
}
},
toggleRight (right) {
const store = this.$store
if (this.user.rights[right]) {
store.state.api.backendInteractor.deleteRight({ user: this.user, right }).then(response => {
if (!response.ok) { return }
store.commit('updateRight', { user: this.user, right, value: false })
})
} else {
store.state.api.backendInteractor.addRight({ user: this.user, right }).then(response => {
if (!response.ok) { return }
store.commit('updateRight', { user: this.user, right, value: true })
})
}
},
toggleActivationStatus () {
this.$store.dispatch('toggleActivationStatus', { user: this.user })
},
deleteUserDialog (show) {
this.showDeleteUserDialog = show
},
deleteUser () {
const store = this.$store
const user = this.user
const { id, name } = user
store.state.api.backendInteractor.deleteUser({ user })
.then(e => {
this.$store.dispatch('markStatusesAsDeleted', status => user.id === status.user.id)
const isProfile = this.$route.name === 'external-user-profile' || this.$route.name === 'user-profile'
const isTargetUser = this.$route.params.name === name || this.$route.params.id === id
if (isProfile && isTargetUser) {
window.history.back()
}
})
},
setToggled (value) {
this.toggled = value
}
}
}
export default UserListMenu

View File

@ -0,0 +1,38 @@
<template>
<div class="UserListMenu">
<Popover
trigger="hover"
placement="left"
remove-padding
>
<template #content>
<div class="dropdown-menu">
<button
v-for="list in lists"
:key="list.id"
class="button-default dropdown-item"
@click="toggleList(list.id)"
>
<span
class="menu-checkbox"
:class="{ 'menu-checkbox-checked': list.inList }"
/>
{{ list.title }}
</button>
</div>
</template>
<template #trigger>
<button class="btn button-default dropdown-item -has-submenu">
{{ $t('lists.manage_lists') }}
<FAIcon
class="chevron-icon"
size="lg"
icon="chevron-right"
/>
</button>
</template>
</Popover>
</div>
</template>
<script src="./user_list_menu.js"></script>

View File

@ -80,11 +80,16 @@
"confirm": "Confirm", "confirm": "Confirm",
"verify": "Verify", "verify": "Verify",
"close": "Close", "close": "Close",
"undo": "Undo",
"yes": "Yes",
"no": "No",
"peek": "Peek", "peek": "Peek",
"role": { "role": {
"admin": "Admin", "admin": "Admin",
"moderator": "Moderator" "moderator": "Moderator"
}, },
"unpin": "Unpin item",
"pin": "Pin item",
"flash_content": "Click to show Flash content using Ruffle (Experimental, may not work).", "flash_content": "Click to show Flash content using Ruffle (Experimental, may not work).",
"flash_security": "Note that this can be potentially dangerous since Flash content is still arbitrary code.", "flash_security": "Note that this can be potentially dangerous since Flash content is still arbitrary code.",
"flash_fail": "Failed to load flash content, see console for details.", "flash_fail": "Failed to load flash content, see console for details.",
@ -149,7 +154,10 @@
"preferences": "Preferences", "preferences": "Preferences",
"timelines": "Timelines", "timelines": "Timelines",
"chats": "Chats", "chats": "Chats",
"lists": "Lists" "lists": "Lists",
"edit_nav_mobile": "Customize navigation bar",
"edit_pinned": "Edit pinned items",
"edit_finish": "Done editing"
}, },
"notifications": { "notifications": {
"broken_favorite": "Unknown status, searching for it…", "broken_favorite": "Unknown status, searching for it…",
@ -987,7 +995,18 @@
"create": "Create", "create": "Create",
"save": "Save changes", "save": "Save changes",
"delete": "Delete list", "delete": "Delete list",
"following_only": "Limit to Following" "following_only": "Limit to Following",
"manage_lists": "Manage lists",
"manage_members": "Manage list members",
"add_members": "Search for more users",
"remove_from_list": "Remove from list",
"add_to_list": "Add to list",
"is_in_list": "Already in list",
"editing_list": "Editing list {listTitle}",
"creating_list": "Creating new list",
"update_title": "Save Title",
"really_delete": "Really delete list?",
"error": "Error manipulating lists: {0}"
}, },
"file_type": { "file_type": {
"audio": "Audio", "audio": "Audio",

View File

@ -15,6 +15,9 @@ const api = {
mastoUserSocketStatus: null, mastoUserSocketStatus: null,
followRequests: [] followRequests: []
}, },
getters: {
followRequestCount: state => state.api.followRequests.length
},
mutations: { mutations: {
setBackendInteractor (state, backendInteractor) { setBackendInteractor (state, backendInteractor) {
state.backendInteractor = backendInteractor state.backendInteractor = backendInteractor

View File

@ -89,7 +89,6 @@ export const defaultState = {
contentColumnWidth: '45rem', contentColumnWidth: '45rem',
notifsColumnWidth: '25rem', notifsColumnWidth: '25rem',
navbarColumnStretch: false, navbarColumnStretch: false,
listsNavigation: false,
greentext: undefined, // instance default greentext: undefined, // instance default
useAtIcon: undefined, // instance default useAtIcon: undefined, // instance default
mentionLinkDisplay: undefined, // instance default mentionLinkDisplay: undefined, // instance default

View File

@ -9,27 +9,43 @@ export const mutations = {
setLists (state, value) { setLists (state, value) {
state.allLists = value state.allLists = value
}, },
setList (state, { id, title }) { setList (state, { listId, title }) {
if (!state.allListsObject[id]) { if (!state.allListsObject[listId]) {
state.allListsObject[id] = {} state.allListsObject[listId] = { accountIds: [] }
} }
state.allListsObject[id].title = title state.allListsObject[listId].title = title
if (!find(state.allLists, { id })) { const entry = find(state.allLists, { id: listId })
state.allLists.push({ id, title }) if (!entry) {
state.allLists.push({ id: listId, title })
} else { } else {
find(state.allLists, { id }).title = title entry.title = title
} }
}, },
setListAccounts (state, { id, accountIds }) { setListAccounts (state, { listId, accountIds }) {
if (!state.allListsObject[id]) { if (!state.allListsObject[listId]) {
state.allListsObject[id] = {} state.allListsObject[listId] = { accountIds: [] }
} }
state.allListsObject[id].accountIds = accountIds state.allListsObject[listId].accountIds = accountIds
}, },
deleteList (state, { id }) { addListAccount (state, { listId, accountId }) {
delete state.allListsObject[id] if (!state.allListsObject[listId]) {
remove(state.allLists, list => list.id === id) state.allListsObject[listId] = { accountIds: [] }
}
state.allListsObject[listId].accountIds.push(accountId)
},
removeListAccount (state, { listId, accountId }) {
if (!state.allListsObject[listId]) {
state.allListsObject[listId] = { accountIds: [] }
}
const { accountIds } = state.allListsObject[listId]
const set = new Set(accountIds)
set.delete(accountId)
state.allListsObject[listId].accountIds = [...set]
},
deleteList (state, { listId }) {
delete state.allListsObject[listId]
remove(state.allLists, list => list.id === listId)
} }
} }
@ -40,37 +56,57 @@ const actions = {
createList ({ rootState, commit }, { title }) { createList ({ rootState, commit }, { title }) {
return rootState.api.backendInteractor.createList({ title }) return rootState.api.backendInteractor.createList({ title })
.then((list) => { .then((list) => {
commit('setList', { id: list.id, title }) commit('setList', { listId: list.id, title })
return list return list
}) })
}, },
fetchList ({ rootState, commit }, { id }) { fetchList ({ rootState, commit }, { listId }) {
return rootState.api.backendInteractor.getList({ id }) return rootState.api.backendInteractor.getList({ listId })
.then((list) => commit('setList', { id: list.id, title: list.title })) .then((list) => commit('setList', { listId: list.id, title: list.title }))
}, },
fetchListAccounts ({ rootState, commit }, { id }) { fetchListAccounts ({ rootState, commit }, { listId }) {
return rootState.api.backendInteractor.getListAccounts({ id }) return rootState.api.backendInteractor.getListAccounts({ listId })
.then((accountIds) => commit('setListAccounts', { id, accountIds })) .then((accountIds) => commit('setListAccounts', { listId, accountIds }))
}, },
setList ({ rootState, commit }, { id, title }) { setList ({ rootState, commit }, { listId, title }) {
rootState.api.backendInteractor.updateList({ id, title }) rootState.api.backendInteractor.updateList({ listId, title })
commit('setList', { id, title }) commit('setList', { listId, title })
}, },
setListAccounts ({ rootState, commit }, { id, accountIds }) { setListAccounts ({ rootState, commit }, { listId, accountIds }) {
const saved = rootState.lists.allListsObject[id].accountIds || [] const saved = rootState.lists.allListsObject[listId].accountIds || []
const added = accountIds.filter(id => !saved.includes(id)) const added = accountIds.filter(id => !saved.includes(id))
const removed = saved.filter(id => !accountIds.includes(id)) const removed = saved.filter(id => !accountIds.includes(id))
commit('setListAccounts', { id, accountIds }) commit('setListAccounts', { listId, accountIds })
if (added.length > 0) { if (added.length > 0) {
rootState.api.backendInteractor.addAccountsToList({ id, accountIds: added }) rootState.api.backendInteractor.addAccountsToList({ listId, accountIds: added })
} }
if (removed.length > 0) { if (removed.length > 0) {
rootState.api.backendInteractor.removeAccountsFromList({ id, accountIds: removed }) rootState.api.backendInteractor.removeAccountsFromList({ listId, accountIds: removed })
} }
}, },
deleteList ({ rootState, commit }, { id }) { addListAccount ({ rootState, commit }, { listId, accountId }) {
rootState.api.backendInteractor.deleteList({ id }) return rootState
commit('deleteList', { id }) .api
.backendInteractor
.addAccountsToList({ listId, accountIds: [accountId] })
.then((result) => {
commit('addListAccount', { listId, accountId })
return result
})
},
removeListAccount ({ rootState, commit }, { listId, accountId }) {
return rootState
.api
.backendInteractor
.removeAccountsFromList({ listId, accountIds: [accountId] })
.then((result) => {
commit('removeListAccount', { listId, accountId })
return result
})
},
deleteList ({ rootState, commit }, { listId }) {
rootState.api.backendInteractor.deleteList({ listId })
commit('deleteList', { listId })
} }
} }

View File

@ -1,5 +1,5 @@
import { toRaw } from 'vue' import { toRaw } from 'vue'
import { isEqual, cloneDeep } from 'lodash' import { isEqual, cloneDeep, set, get, clamp, flatten, groupBy, findLastIndex, takeRight } from 'lodash'
import { CURRENT_UPDATE_COUNTER } from 'src/components/update_notification/update_notification.js' import { CURRENT_UPDATE_COUNTER } from 'src/components/update_notification/update_notification.js'
export const VERSION = 1 export const VERSION = 1
@ -14,14 +14,21 @@ export const defaultState = {
// storage of flags - stuff that can only be set and incremented // storage of flags - stuff that can only be set and incremented
flagStorage: { flagStorage: {
updateCounter: 0, // Counter for most recent update notification seen 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 reset: 0 // special flag that can be used to force-reset all flags, debug purposes only
// special reset codes: // special reset codes:
// 1000: trim keys to those known by currently running FE // 1000: trim keys to those known by currently running FE
// 1001: same as above + reset everything to 0 // 1001: same as above + reset everything to 0
}, },
prefsStorage: {
_journal: [],
simple: {
dontShowUpdateNotifs: false,
collapseNav: false
},
collections: {
pinnedNavItems: ['home', 'dms', 'chats']
}
},
// raw data // raw data
raw: null, raw: null,
// local cache // local cache
@ -33,14 +40,43 @@ export const newUserFlags = {
updateCounter: CURRENT_UPDATE_COUNTER // new users don't need to see update notification 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, ...data,
_user: userName,
_timestamp: Date.now(), _timestamp: Date.now(),
_version: VERSION _version: VERSION
}) })
const _checkValidity = (data) => data._timestamp > 0 && data._version > 0 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) => { export const _getRecentData = (cache, live) => {
const result = { recent: null, stale: null, needUpload: false } const result = { recent: null, stale: null, needUpload: false }
const cacheValid = _checkValidity(cache || {}) const cacheValid = _checkValidity(cache || {})
@ -85,6 +121,8 @@ export const _getAllFlags = (recent, stale) => {
} }
export const _mergeFlags = (recent, stale, allFlagKeys) => { export const _mergeFlags = (recent, stale, allFlagKeys) => {
if (!stale.flagStorage) return recent.flagStorage
if (!recent.flagStorage) return stale.flagStorage
return Object.fromEntries(allFlagKeys.map(flag => { return Object.fromEntries(allFlagKeys.map(flag => {
const recentFlag = recent.flagStorage[flag] const recentFlag = recent.flagStorage[flag]
const staleFlag = stale.flagStorage[flag] const staleFlag = stale.flagStorage[flag]
@ -93,6 +131,88 @@ export const _mergeFlags = (recent, stale, allFlagKeys) => {
})) }))
} }
const _mergeJournal = (...journals) => {
// Ignore invalid journal entries
const allJournals = flatten(
journals.map(j => Array.isArray(j) ? j : [])
).filter(entry =>
Object.prototype.hasOwnProperty.call(entry, 'path') &&
Object.prototype.hasOwnProperty.call(entry, 'operation') &&
Object.prototype.hasOwnProperty.call(entry, 'args') &&
Object.prototype.hasOwnProperty.call(entry, 'timestamp')
)
const grouped = groupBy(allJournals, 'path')
const trimmedGrouped = Object.entries(grouped).map(([path, journal]) => {
// side effect
journal.sort((a, b) => a.timestamp > b.timestamp ? 1 : -1)
if (path.startsWith('collections')) {
const lastRemoveIndex = findLastIndex(journal, ({ operation }) => operation === 'removeFromCollection')
// everything before last remove is unimportant
if (lastRemoveIndex > 0) {
return journal.slice(lastRemoveIndex)
} else {
// everything else doesn't need trimming
return journal
}
} else if (path.startsWith('simple')) {
// Only the last record is important
return takeRight(journal)
} else {
return journal
}
})
return flatten(trimmedGrouped)
.sort((a, b) => a.timestamp > b.timestamp ? 1 : -1)
}
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 }) => {
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) => { export const _resetFlags = (totalFlags, knownKeys = defaultState.flagStorage) => {
let result = { ...totalFlags } let result = { ...totalFlags }
const allFlagKeys = Object.keys(totalFlags) const allFlagKeys = Object.keys(totalFlags)
@ -149,10 +269,17 @@ export const _doMigrations = (cache) => {
} }
export const mutations = { export const mutations = {
clearServerSideStorage (state, userData) {
state = { ...cloneDeep(defaultState) }
},
setServerSideStorage (state, userData) { setServerSideStorage (state, userData) {
const live = userData.storage const live = userData.storage
state.raw = live state.raw = live
let cache = state.cache let cache = state.cache
if (cache && cache._user !== userData.fqn) {
console.warn('cache belongs to another user! reinitializing local cache!')
cache = null
}
cache = _doMigrations(cache) cache = _doMigrations(cache)
@ -165,7 +292,8 @@ export const mutations = {
if (recent === null) { if (recent === null) {
console.debug(`Data is empty, initializing for ${userNew ? 'new' : 'existing'} user`) console.debug(`Data is empty, initializing for ${userNew ? 'new' : 'existing'} user`)
recent = _wrapData({ recent = _wrapData({
flagStorage: { ...flagsTemplate } flagStorage: { ...flagsTemplate },
prefsStorage: { ...defaultState.prefsStorage }
}) })
} }
@ -180,17 +308,23 @@ export const mutations = {
const allFlagKeys = _getAllFlags(recent, stale) const allFlagKeys = _getAllFlags(recent, stale)
let totalFlags let totalFlags
let totalPrefs
if (dirty) { if (dirty) {
// Merge the flags // Merge the flags
console.debug('Merging the flags...') console.debug('Merging the data...')
totalFlags = _mergeFlags(recent, stale, allFlagKeys) totalFlags = _mergeFlags(recent, stale, allFlagKeys)
_verifyPrefs(recent)
_verifyPrefs(stale)
totalPrefs = _mergePrefs(recent.prefsStorage, stale.prefsStorage)
} else { } else {
totalFlags = recent.flagStorage totalFlags = recent.flagStorage
totalPrefs = recent.prefsStorage
} }
totalFlags = _resetFlags(totalFlags) totalFlags = _resetFlags(totalFlags)
recent.flagStorage = totalFlags recent.flagStorage = { ...flagsTemplate, ...totalFlags }
recent.prefsStorage = { ...defaultState.prefsStorage, ...totalPrefs }
state.dirty = dirty || needsUpload state.dirty = dirty || needsUpload
state.cache = recent state.cache = recent
@ -199,10 +333,72 @@ export const mutations = {
state.cache._timestamp = Math.min(stale._timestamp, recent._timestamp) state.cache._timestamp = Math.min(stale._timestamp, recent._timestamp)
} }
state.flagStorage = state.cache.flagStorage state.flagStorage = state.cache.flagStorage
state.prefsStorage = state.cache.prefsStorage
}, },
setFlag (state, { flag, value }) { setFlag (state, { flag, value }) {
state.flagStorage[flag] = value state.flagStorage[flag] = value
state.dirty = true 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 +410,16 @@ const serverSideStorage = {
actions: { actions: {
pushServerSideStorage ({ state, rootState, commit }, { force = false } = {}) { pushServerSideStorage ({ state, rootState, commit }, { force = false } = {}) {
const needPush = state.dirty || force const needPush = state.dirty || force
console.log(needPush)
if (!needPush) return if (!needPush) return
state.cache = _wrapData({ commit('updateCache', { username: rootState.users.currentUser.fqn })
flagStorage: toRaw(state.flagStorage)
})
const params = { pleroma_settings_store: { 'pleroma-fe': state.cache } } const params = { pleroma_settings_store: { 'pleroma-fe': state.cache } }
rootState.api.backendInteractor rootState.api.backendInteractor
.updateProfile({ params }) .updateProfile({ params })
.then((user) => commit('setServerSideStorage', user)) .then((user) => {
state.dirty = false commit('setServerSideStorage', user)
state.dirty = false
})
} }
} }
} }

View File

@ -171,6 +171,9 @@ export const mutations = {
state.relationships[relationship.id] = relationship state.relationships[relationship.id] = relationship
}) })
}, },
updateUserInLists (state, { id, inLists }) {
state.usersObject[id].inLists = inLists
},
saveBlockIds (state, blockIds) { saveBlockIds (state, blockIds) {
state.currentUser.blockIds = blockIds state.currentUser.blockIds = blockIds
}, },
@ -298,6 +301,12 @@ const users = {
.then((relationships) => store.commit('updateUserRelationship', relationships)) .then((relationships) => store.commit('updateUserRelationship', relationships))
} }
}, },
fetchUserInLists (store, id) {
if (store.state.currentUser) {
store.rootState.api.backendInteractor.fetchUserInLists({ id })
.then((inLists) => store.commit('updateUserInLists', { id, inLists }))
}
},
fetchBlocks (store) { fetchBlocks (store) {
return store.rootState.api.backendInteractor.fetchBlocks() return store.rootState.api.backendInteractor.fetchBlocks()
.then((blocks) => { .then((blocks) => {
@ -509,6 +518,7 @@ const users = {
store.dispatch('stopFetchingTimeline', 'friends') store.dispatch('stopFetchingTimeline', 'friends')
store.commit('setBackendInteractor', backendInteractorService(store.getters.getToken())) store.commit('setBackendInteractor', backendInteractorService(store.getters.getToken()))
store.dispatch('stopFetchingNotifications') store.dispatch('stopFetchingNotifications')
store.dispatch('stopFetchingLists')
store.dispatch('stopFetchingFollowRequests') store.dispatch('stopFetchingFollowRequests')
store.commit('clearNotifications') store.commit('clearNotifications')
store.commit('resetStatuses') store.commit('resetStatuses')
@ -516,6 +526,7 @@ const users = {
store.dispatch('setLastTimeline', 'public-timeline') store.dispatch('setLastTimeline', 'public-timeline')
store.dispatch('setLayoutWidth', windowWidth()) store.dispatch('setLayoutWidth', windowWidth())
store.dispatch('setLayoutHeight', windowHeight()) store.dispatch('setLayoutHeight', windowHeight())
store.commit('clearServerSideStorage')
}) })
}, },
loginUser (store, accessToken) { loginUser (store, accessToken) {
@ -562,6 +573,12 @@ const users = {
store.dispatch('startFetchingChats') store.dispatch('startFetchingChats')
} }
store.dispatch('startFetchingLists')
if (user.locked) {
store.dispatch('startFetchingFollowRequests')
}
if (store.getters.mergedConfig.useStreamingApi) { if (store.getters.mergedConfig.useStreamingApi) {
store.dispatch('fetchTimeline', 'friends', { since: null }) store.dispatch('fetchTimeline', 'friends', { since: null })
store.dispatch('fetchNotifications', { since: null }) store.dispatch('fetchNotifications', { since: null })

View File

@ -46,7 +46,7 @@
.panel-footer { .panel-footer {
--panel-heading-height-padding: 0.6em; --panel-heading-height-padding: 0.6em;
--__panel-heading-height: 3.2em; --__panel-heading-height: 3.2em;
--__panel-heading-height-inner: calc(var(--__panel-heading-height) - 2 * var(--panel-heading-height-padding)); --__panel-heading-height-inner: calc(var(--__panel-heading-height) - 2 * var(--panel-heading-height-padding, 0));
position: relative; position: relative;
box-sizing: border-box; box-sizing: border-box;
@ -57,7 +57,7 @@
grid-column-gap: 0.5em; grid-column-gap: 0.5em;
flex: none; flex: none;
background-size: cover; background-size: cover;
padding: 0.6em; padding: var(--panel-heading-height-padding);
height: var(--__panel-heading-height); height: var(--__panel-heading-height);
line-height: var(--__panel-heading-height-inner); line-height: var(--__panel-heading-height-inner);
z-index: 4; z-index: 4;
@ -147,6 +147,15 @@
color: var(--panelLink, $fallback--link); color: var(--panelLink, $fallback--link);
} }
.button-unstyled:hover,
a:hover {
i[class*=icon-],
.svg-inline--fa,
.iconLetter {
color: var(--panelText);
}
}
.faint { .faint {
background-color: transparent; background-color: transparent;
color: $fallback--faint; color: $fallback--faint;

View File

@ -53,6 +53,7 @@ const MASTODON_USER_URL = '/api/v1/accounts'
const MASTODON_USER_LOOKUP_URL = '/api/v1/accounts/lookup' const MASTODON_USER_LOOKUP_URL = '/api/v1/accounts/lookup'
const MASTODON_USER_RELATIONSHIPS_URL = '/api/v1/accounts/relationships' const MASTODON_USER_RELATIONSHIPS_URL = '/api/v1/accounts/relationships'
const MASTODON_USER_TIMELINE_URL = id => `/api/v1/accounts/${id}/statuses` const MASTODON_USER_TIMELINE_URL = id => `/api/v1/accounts/${id}/statuses`
const MASTODON_USER_IN_LISTS = id => `/api/v1/accounts/${id}/lists`
const MASTODON_LIST_URL = id => `/api/v1/lists/${id}` const MASTODON_LIST_URL = id => `/api/v1/lists/${id}`
const MASTODON_LIST_TIMELINE_URL = id => `/api/v1/timelines/list/${id}` const MASTODON_LIST_TIMELINE_URL = id => `/api/v1/timelines/list/${id}`
const MASTODON_LIST_ACCOUNTS_URL = id => `/api/v1/lists/${id}/accounts` const MASTODON_LIST_ACCOUNTS_URL = id => `/api/v1/lists/${id}/accounts`
@ -263,6 +264,13 @@ const unfollowUser = ({ id, credentials }) => {
}).then((data) => data.json()) }).then((data) => data.json())
} }
const fetchUserInLists = ({ id, credentials }) => {
const url = MASTODON_USER_IN_LISTS(id)
return fetch(url, {
headers: authHeaders(credentials)
}).then((data) => data.json())
}
const pinOwnStatus = ({ id, credentials }) => { const pinOwnStatus = ({ id, credentials }) => {
return promisedRequest({ url: MASTODON_PIN_OWN_STATUS(id), credentials, method: 'POST' }) return promisedRequest({ url: MASTODON_PIN_OWN_STATUS(id), credentials, method: 'POST' })
.then((data) => parseStatus(data)) .then((data) => parseStatus(data))
@ -428,14 +436,14 @@ const createList = ({ title, credentials }) => {
}).then((data) => data.json()) }).then((data) => data.json())
} }
const getList = ({ id, credentials }) => { const getList = ({ listId, credentials }) => {
const url = MASTODON_LIST_URL(id) const url = MASTODON_LIST_URL(listId)
return fetch(url, { headers: authHeaders(credentials) }) return fetch(url, { headers: authHeaders(credentials) })
.then((data) => data.json()) .then((data) => data.json())
} }
const updateList = ({ id, title, credentials }) => { const updateList = ({ listId, title, credentials }) => {
const url = MASTODON_LIST_URL(id) const url = MASTODON_LIST_URL(listId)
const headers = authHeaders(credentials) const headers = authHeaders(credentials)
headers['Content-Type'] = 'application/json' headers['Content-Type'] = 'application/json'
@ -446,15 +454,15 @@ const updateList = ({ id, title, credentials }) => {
}) })
} }
const getListAccounts = ({ id, credentials }) => { const getListAccounts = ({ listId, credentials }) => {
const url = MASTODON_LIST_ACCOUNTS_URL(id) const url = MASTODON_LIST_ACCOUNTS_URL(listId)
return fetch(url, { headers: authHeaders(credentials) }) return fetch(url, { headers: authHeaders(credentials) })
.then((data) => data.json()) .then((data) => data.json())
.then((data) => data.map(({ id }) => id)) .then((data) => data.map(({ id }) => id))
} }
const addAccountsToList = ({ id, accountIds, credentials }) => { const addAccountsToList = ({ listId, accountIds, credentials }) => {
const url = MASTODON_LIST_ACCOUNTS_URL(id) const url = MASTODON_LIST_ACCOUNTS_URL(listId)
const headers = authHeaders(credentials) const headers = authHeaders(credentials)
headers['Content-Type'] = 'application/json' headers['Content-Type'] = 'application/json'
@ -465,8 +473,8 @@ const addAccountsToList = ({ id, accountIds, credentials }) => {
}) })
} }
const removeAccountsFromList = ({ id, accountIds, credentials }) => { const removeAccountsFromList = ({ listId, accountIds, credentials }) => {
const url = MASTODON_LIST_ACCOUNTS_URL(id) const url = MASTODON_LIST_ACCOUNTS_URL(listId)
const headers = authHeaders(credentials) const headers = authHeaders(credentials)
headers['Content-Type'] = 'application/json' headers['Content-Type'] = 'application/json'
@ -477,8 +485,8 @@ const removeAccountsFromList = ({ id, accountIds, credentials }) => {
}) })
} }
const deleteList = ({ id, credentials }) => { const deleteList = ({ listId, credentials }) => {
const url = MASTODON_LIST_URL(id) const url = MASTODON_LIST_URL(listId)
return fetch(url, { return fetch(url, {
method: 'DELETE', method: 'DELETE',
headers: authHeaders(credentials) headers: authHeaders(credentials)
@ -1584,7 +1592,8 @@ const apiService = {
sendChatMessage, sendChatMessage,
readChat, readChat,
deleteChatMessage, deleteChatMessage,
setReportState setReportState,
fetchUserInLists
} }
export default apiService export default apiService

View File

@ -43,11 +43,13 @@ export const parseUser = (data) => {
// case for users in "mentions" property for statuses in MastoAPI // case for users in "mentions" property for statuses in MastoAPI
const mastoShort = masto && !Object.prototype.hasOwnProperty.call(data, 'avatar') const mastoShort = masto && !Object.prototype.hasOwnProperty.call(data, 'avatar')
output.inLists = null
output.id = String(data.id) output.id = String(data.id)
output._original = data // used for server-side settings output._original = data // used for server-side settings
if (masto) { if (masto) {
output.screen_name = data.acct output.screen_name = data.acct
output.fqn = data.fqn
output.statusnet_profile_url = data.url output.statusnet_profile_url = data.url
// There's nothing else to get // There's nothing else to get

View File

@ -17,13 +17,13 @@ describe('The lists module', () => {
const list = { id: '1', title: 'testList' } const list = { id: '1', title: 'testList' }
const modList = { id: '1', title: 'anotherTestTitle' } const modList = { id: '1', title: 'anotherTestTitle' }
mutations.setList(state, list) mutations.setList(state, { listId: list.id, title: list.title })
expect(state.allListsObject[list.id]).to.eql({ title: list.title }) expect(state.allListsObject[list.id]).to.eql({ title: list.title, accountIds: [] })
expect(state.allLists).to.have.length(1) expect(state.allLists).to.have.length(1)
expect(state.allLists[0]).to.eql(list) expect(state.allLists[0]).to.eql(list)
mutations.setList(state, modList) mutations.setList(state, { listId: modList.id, title: modList.title })
expect(state.allListsObject[modList.id]).to.eql({ title: modList.title }) expect(state.allListsObject[modList.id]).to.eql({ title: modList.title, accountIds: [] })
expect(state.allLists).to.have.length(1) expect(state.allLists).to.have.length(1)
expect(state.allLists[0]).to.eql(modList) expect(state.allLists[0]).to.eql(modList)
}) })
@ -33,10 +33,10 @@ describe('The lists module', () => {
const list = { id: '1', accountIds: ['1', '2', '3'] } const list = { id: '1', accountIds: ['1', '2', '3'] }
const modList = { id: '1', accountIds: ['3', '4', '5'] } const modList = { id: '1', accountIds: ['3', '4', '5'] }
mutations.setListAccounts(state, list) mutations.setListAccounts(state, { listId: list.id, accountIds: list.accountIds })
expect(state.allListsObject[list.id]).to.eql({ accountIds: list.accountIds }) expect(state.allListsObject[list.id]).to.eql({ accountIds: list.accountIds })
mutations.setListAccounts(state, modList) mutations.setListAccounts(state, { listId: modList.id, accountIds: modList.accountIds })
expect(state.allListsObject[modList.id]).to.eql({ accountIds: modList.accountIds }) expect(state.allListsObject[modList.id]).to.eql({ accountIds: modList.accountIds })
}) })
@ -47,9 +47,9 @@ describe('The lists module', () => {
1: { title: 'testList', accountIds: ['1', '2', '3'] } 1: { title: 'testList', accountIds: ['1', '2', '3'] }
} }
} }
const id = '1' const listId = '1'
mutations.deleteList(state, { id }) mutations.deleteList(state, { listId })
expect(state.allLists).to.have.length(0) expect(state.allLists).to.have.length(0)
expect(state.allListsObject).to.eql({}) expect(state.allListsObject).to.eql({})
}) })

View File

@ -4,9 +4,11 @@ import {
VERSION, VERSION,
COMMAND_TRIM_FLAGS, COMMAND_TRIM_FLAGS,
COMMAND_TRIM_FLAGS_AND_RESET, COMMAND_TRIM_FLAGS_AND_RESET,
_moveItemInArray,
_getRecentData, _getRecentData,
_getAllFlags, _getAllFlags,
_mergeFlags, _mergeFlags,
_mergePrefs,
_resetFlags, _resetFlags,
mutations, mutations,
defaultState, defaultState,
@ -28,6 +30,7 @@ describe('The serverSideStorage module', () => {
expect(state.cache._version).to.eql(VERSION) expect(state.cache._version).to.eql(VERSION)
expect(state.cache._timestamp).to.be.a('number') expect(state.cache._timestamp).to.be.a('number')
expect(state.cache.flagStorage).to.eql(defaultState.flagStorage) 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', () => { 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._version).to.eql(VERSION)
expect(state.cache._timestamp).to.be.a('number') expect(state.cache._timestamp).to.be.a('number')
expect(state.cache.flagStorage).to.eql(newUserFlags) 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', () => { it('should merge flags even if remote timestamp is older', () => {
@ -57,6 +61,9 @@ describe('The serverSideStorage module', () => {
flagStorage: { flagStorage: {
...defaultState.flagStorage, ...defaultState.flagStorage,
updateCounter: 1 updateCounter: 1
},
prefsStorage: {
...defaultState.prefsStorage
} }
} }
} }
@ -99,9 +106,62 @@ describe('The serverSideStorage module', () => {
expect(state.cache.flagStorage).to.eql(defaultState.flagStorage) expect(state.cache.flagStorage).to.eql(defaultState.flagStorage)
}) })
}) })
describe('setPreference', () => {
const { setPreference, updateCache, addCollectionPreference, removeCollectionPreference } = 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', () => {
const state = cloneDeep(defaultState)
setPreference(state, { path: 'simple.testing', value: 1 })
setPreference(state, { path: 'simple.testing', value: 2 })
addCollectionPreference(state, { path: 'collections.testing', value: 2 })
removeCollectionPreference(state, { path: 'collections.testing', value: 2 })
updateCache(state, { username: 'test' })
expect(state.prefsStorage.simple.testing).to.eql(2)
expect(state.prefsStorage.collections.testing).to.eql([])
expect(state.prefsStorage._journal.length).to.eql(2)
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
})
expect(state.prefsStorage._journal[1]).to.eql({
path: 'collections.testing',
operation: 'removeFromCollection',
args: [2],
// should have A timestamp, we don't really care what it is
timestamp: state.prefsStorage._journal[1].timestamp
})
})
})
}) })
describe('helper functions', () => { 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', () => { describe('_getRecentData', () => {
it('should handle nulls correctly', () => { it('should handle nulls correctly', () => {
expect(_getRecentData(null, null)).to.eql({ recent: null, stale: null, needUpload: true }) expect(_getRecentData(null, null)).to.eql({ recent: null, stale: null, needUpload: true })
@ -157,6 +217,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', () => { describe('_resetFlags', () => {
it('should reset all known flags to 0 when reset flag is set to > 0 and < 9000', () => { 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 } const totalFlags = { a: 0, b: 3, reset: 1 }