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 () {
return this.$route.name === 'chat' || this.$route.name === 'chats'
},
isListEdit () {
return this.$route.name === 'lists-edit'
},
newPostButtonShown () {
if (this.isChats) return false
if (this.isListEdit) return false
return this.$store.getters.mergedConfig.alwaysShowNewPostButton || this.layoutType === 'mobile'
},
showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel },

View file

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

View file

@ -36,7 +36,7 @@
<div
id="main-scroller"
class="column main"
:class="{ '-full-height': isChats }"
:class="{ '-full-height': isChats || isListEdit }"
>
<div
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 ListsTimeline from 'components/lists_timeline/lists_timeline.vue'
import ListsEdit from 'components/lists_edit/lists_edit.vue'
import NavPanel from 'src/components/nav_panel/nav_panel.vue'
export default (store) => {
const validateAuthenticatedRoute = (to, from, next) => {
@ -79,7 +80,9 @@ export default (store) => {
{ name: 'legacy-user-profile', path: '/:name', component: UserProfile },
{ name: 'lists', path: '/lists', component: Lists },
{ name: 'lists-timeline', path: '/lists/:id', component: ListsTimeline },
{ name: 'lists-edit', path: '/lists/:id/edit', component: ListsEdit }
{ name: 'lists-edit', path: '/lists/:id/edit', component: ListsEdit },
{ name: '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) {

View file

@ -1,6 +1,7 @@
import { mapState } from 'vuex'
import ProgressButton from '../progress_button/progress_button.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 {
faEllipsisV
@ -19,7 +20,8 @@ const AccountActions = {
},
components: {
ProgressButton,
Popover
Popover,
UserListMenu
},
methods: {
showRepeats () {

View file

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

View file

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

View file

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

View file

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

View file

@ -1,21 +1,15 @@
<template>
<div v-if="isNew">
<ListsNew @cancel="cancelNewList" />
</div>
<div
v-else
class="settings panel panel-default"
>
<div class="Lists panel panel-default">
<div class="panel-heading">
<div class="title">
{{ $t('lists.lists') }}
</div>
<button
class="button-default"
@click="newList"
<router-link
:to="{ name: 'lists-new' }"
class="button-default btn new-list-button"
>
{{ $t("lists.new") }}
</button>
</router-link>
</div>
<div class="panel-body">
<ListsCard
@ -29,3 +23,11 @@
</template>
<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 BasicUserCard from '../basic_user_card/basic_user_card.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 TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faSearch,
@ -17,22 +19,33 @@ const ListsNew = {
components: {
BasicUserCard,
UserAvatar,
ListsUserSearch
ListsUserSearch,
TabSwitcher,
PanelLoading
},
data () {
return {
title: '',
userIds: [],
selectedUserIds: []
titleDraft: '',
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 () {
this.$store.dispatch('fetchList', { id: this.id })
.then(() => { this.title = this.findListTitle(this.id) })
this.$store.dispatch('fetchListAccounts', { id: this.id })
if (!this.id) return
this.$store.dispatch('fetchList', { listId: this.id })
.then(() => {
this.selectedUserIds = this.findListAccounts(this.id)
this.selectedUserIds.forEach(userId => {
this.title = this.findListTitle(this.id)
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)
})
})
@ -41,11 +54,12 @@ const ListsNew = {
id () {
return this.$route.params.id
},
users () {
return this.userIds.map(userId => this.findUser(userId))
membersUsers () {
return [...this.membersUserIds, ...this.addedUserIds]
.map(userId => this.findUser(userId)).filter(user => user)
},
selectedUsers () {
return this.selectedUserIds.map(userId => this.findUser(userId)).filter(user => user)
searchUsers () {
return this.searchUserIds.map(userId => this.findUser(userId)).filter(user => user)
},
...mapState({
currentUser: state => state.users.currentUser
@ -56,33 +70,73 @@ const ListsNew = {
onInput () {
this.search(this.query)
},
selectUser (user) {
if (this.selectedUserIds.includes(user.id)) {
this.removeUser(user.id)
toggleRemoveMember (user) {
if (this.removedUserIds.has(user.id)) {
this.id && this.addUser(user)
this.removedUserIds.delete(user.id)
} else {
this.addUser(user)
this.id && this.removeUser(user.id)
this.removedUserIds.add(user.id)
}
},
isSelected (user) {
return this.selectedUserIds.includes(user.id)
toggleAddFromSearch (user) {
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) {
this.selectedUserIds.push(user.id)
this.$store.dispatch('addListAccount', { accountId: this.user.id, listId: this.id })
},
removeUser (userId) {
this.selectedUserIds = this.selectedUserIds.filter(id => id !== userId)
this.$store.dispatch('removeListAccount', { accountId: this.user.id, listId: this.id })
},
onResults (results) {
this.userIds = results
onSearchLoading (results) {
this.searchLoading = true
},
updateList () {
this.$store.dispatch('setList', { id: this.id, title: this.title })
this.$store.dispatch('setListAccounts', { id: this.id, accountIds: this.selectedUserIds })
this.$router.push({ name: 'lists-timeline', params: { id: this.id } })
onSearchLoadingDone (results) {
this.searchLoading = false
},
onSearchResults (results) {
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 () {
this.$store.dispatch('deleteList', { id: this.id })
this.$store.dispatch('deleteList', { listId: this.id })
this.$router.push({ name: 'lists' })
}
}

View file

@ -1,8 +1,8 @@
<template>
<div class="panel-default panel list-edit">
<div class="panel-default panel ListEdit">
<div
ref="header"
class="panel-heading"
class="panel-heading list-edit-heading"
>
<button
class="button-unstyled go-back-button"
@ -13,54 +13,151 @@
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 class="title">
<i18n-t
v-if="id"
keypath="lists.editing_list"
>
<template #listTitle>
{{ title }}
</template>
</i18n-t>
<i18n-t
v-else
keypath="lists.creating_list"
/>
</div>
</div>
<ListsUserSearch @results="onResults" />
<div class="member-list">
<div
v-for="user in users"
:key="user.id"
class="member"
>
<BasicUserCard
:user="user"
:class="isSelected(user) ? 'selected' : ''"
@click.capture.prevent="selectUser(user)"
/>
<div class="panel-body">
<div class="input-wrap">
<label for="list-edit-title">{{ $t('lists.title') }}</label>
{{ ' ' }}
<input
id="list-edit-title"
ref="title"
v-model="titleDraft"
>
<button
v-if="id"
class="btn button-default follow-button"
@click="updateListTitle"
>
{{ $t('lists.update_title') }}
</button>
</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>
<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>
</template>
@ -69,28 +166,43 @@
<style lang="scss">
@import '../../_variables.scss';
.list-edit {
.input-wrap {
display: flex;
margin: 0.7em 0.5em 0.7em 0.5em;
.ListEdit {
--panel-body-padding: 0.5em;
input {
width: 100%;
}
height: calc(100vh - var(--navbar-height));
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 {
margin-right: 0.3em;
}
.member-list {
.users-list {
padding-bottom: 0.7rem;
overflow-y: auto;
}
.basic-user-card:hover,
.basic-user-card.selected {
cursor: pointer;
background-color: var(--selectedPost, $fallback--lightBg);
& .search-list,
& .members-list {
overflow: hidden;
flex-direction: column;
min-height: 0;
}
.go-back-button {
@ -102,7 +214,15 @@
}
.btn {
margin: 0.5em;
margin: 0 0.5em;
}
.panel-footer {
grid-template-columns: minmax(10%, 1fr);
.footer-button {
min-width: 9em;
}
}
}
</style>

View file

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

View file

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

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.$store.dispatch('stopFetchingTimeline', '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 })
}
}
},
created () {
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 })
},
unmounted () {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,10 @@
import TimelineMenuContent from '../timeline_menu/timeline_menu_content.vue'
import ListsMenuContent from '../lists_menu/lists_menu_content.vue'
import ListsMenuContent from 'src/components/lists_menu/lists_menu_content.vue'
import { mapState, mapGetters } from 'vuex'
import { TIMELINES, ROOT_ITEMS } from 'src/components/navigation/navigation.js'
import { filterNavigation } from 'src/components/navigation/filter.js'
import NavigationEntry from 'src/components/navigation/navigation_entry.vue'
import NavigationPins from 'src/components/navigation/navigation_pins.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
@ -30,21 +34,23 @@ library.add(
faStream,
faList
)
const NavPanel = {
props: ['forceExpand', 'forceEditMode'],
created () {
if (this.currentUser && this.currentUser.locked) {
this.$store.dispatch('startFetchingFollowRequests')
}
},
components: {
TimelineMenuContent,
ListsMenuContent
ListsMenuContent,
NavigationEntry,
NavigationPins,
Checkbox
},
data () {
return {
editMode: 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: {
@ -53,19 +59,62 @@ const NavPanel = {
},
toggleLists () {
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: {
listsNavigation () {
return this.$store.getters.mergedConfig.listsNavigation
},
...mapState({
currentUser: state => state.users.currentUser,
followRequestCount: state => state.api.followRequests.length,
privateMode: state => state.instance.private,
federating: state => state.instance.federating,
pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable
pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable,
pinnedItems: state => new Set(state.serverSideStorage.prefsStorage.collections.pinnedNavItems),
collapsed: state => state.serverSideStorage.prefsStorage.simple.collapseNav
}),
timelinesItems () {
return filterNavigation(
Object
.entries({ ...TIMELINES })
.map(([k, v]) => ({ ...v, name: k })),
{
hasChats: this.pleromaChatMessagesAvailable,
isFederating: this.federating,
isPrivate: this.privateMode,
currentUser: this.currentUser
}
)
},
rootItems () {
return filterNavigation(
Object
.entries({ ...ROOT_ITEMS })
.map(([k, v]) => ({ ...v, name: k })),
{
hasChats: this.pleromaChatMessagesAvailable,
isFederating: this.federating,
isPrivate: this.privateMode,
currentUser: this.currentUser
}
)
},
...mapGetters(['unreadChatCount'])
}
}

View file

@ -1,135 +1,99 @@
<template>
<div class="NavPanel">
<div class="panel panel-default">
<ul>
<li v-if="currentUser || !privateMode">
<button
class="button-unstyled menu-item"
@click="toggleTimelines"
>
<FAIcon
fixed-width
class="fa-scale-110"
icon="stream"
/>{{ $t("nav.timelines") }}
<FAIcon
class="timelines-chevron"
fixed-width
:icon="showTimelines ? 'chevron-up' : 'chevron-down'"
<div
v-if="!forceExpand"
class="panel-heading nav-panel-heading"
>
<NavigationPins :limit="6" />
<div class="spacer" />
<button
class="button-unstyled"
@click="toggleCollapse"
>
<FAIcon
class="timelines-chevron"
fixed-width
:icon="collapsed ? 'chevron-down' : 'chevron-up'"
/>
</button>
</div>
<ul
v-if="!collapsed || forceExpand"
class="panel-body"
>
<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>
</li>
<li v-if="currentUser && listsNavigation">
<button
class="button-unstyled menu-item"
@click="toggleLists"
>
<router-link
:to="{ name: 'lists' }"
@click.stop
>
<FAIcon
fixed-width
class="fa-scale-110"
icon="list"
/>{{ $t("nav.lists") }}
</router-link>
<FAIcon
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">
</div>
<NavigationEntry
v-if="currentUser"
:show-pin="false"
:item="{ icon: 'list', label: 'nav.lists' }"
:aria-expanded="showLists ? 'true' : 'false'"
@click="toggleLists"
>
<router-link
:title="$t('lists.manage_lists')"
class="extra-button"
:to="{ name: 'lists' }"
@click.stop
>
<button
class="button-unstyled menu-item"
@click="toggleLists"
>
<FAIcon
fixed-width
class="fa-scale-110"
icon="list"
/>{{ $t("nav.lists") }}
</button>
</router-link>
</li>
<li v-if="currentUser">
<router-link
class="menu-item"
:to="{ name: 'interactions', params: { username: currentUser.screen_name } }"
>
<FAIcon
class="extra-button"
fixed-width
class="fa-scale-110"
icon="bell"
/>{{ $t("nav.interactions") }}
icon="wrench"
/>
</router-link>
</li>
<li v-if="currentUser && pleromaChatMessagesAvailable">
<router-link
class="menu-item"
:to="{ name: 'chats', params: { username: currentUser.screen_name } }"
>
<div
v-if="unreadChatCount"
class="badge badge-notification"
>
{{ unreadChatCount }}
</div>
<FAIcon
fixed-width
class="fa-scale-110"
icon="comments"
/>{{ $t("nav.chats") }}
</router-link>
</li>
<li v-if="currentUser && currentUser.locked">
<router-link
class="menu-item"
:to="{ name: 'friend-requests' }"
>
<FAIcon
fixed-width
class="fa-scale-110"
icon="user-plus"
/>{{ $t("nav.friend_requests") }}
<span
v-if="followRequestCount > 0"
class="badge badge-notification"
>
{{ followRequestCount }}
</span>
</router-link>
</li>
<li>
<router-link
class="menu-item"
:to="{ name: 'about' }"
>
<FAIcon
fixed-width
class="fa-scale-110"
icon="info-circle"
/>{{ $t("nav.about") }}
</router-link>
</li>
<FAIcon
class="timelines-chevron"
fixed-width
:icon="showLists ? 'chevron-up' : 'chevron-down'"
/>
</NavigationEntry>
<div
v-show="showLists"
class="timelines-background"
>
<ListsMenuContent
:show-pin="editMode || forceEditMode"
class="timelines"
/>
</div>
<NavigationEntry
v-for="item in rootItems"
:key="item.name"
:show-pin="editMode || forceEditMode"
:item="item"
/>
<NavigationEntry
v-if="!forceEditMode && currentUser"
:show-pin="false"
:item="{ label: editMode ? $t('nav.edit_finish') : $t('nav.edit_pinned'), icon: editMode ? 'check' : 'wrench' }"
@click="toggleEditMode"
/>
</ul>
</div>
</div>
@ -180,54 +144,23 @@
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 {
margin-left: 0.8em;
margin-right: 0.8em;
font-size: 1.1em;
}
.menu-item {
.timelines-chevron {
margin-right: 0;
}
}
.timelines-background {
padding: 0 0 0 0.6em;
background-color: $fallback--lightBg;
background-color: var(--selectedMenu, $fallback--lightBg);
border-top: 1px solid;
border-bottom: 1px solid;
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
}
@ -237,14 +170,9 @@
background-color: var(--bg, $fallback--bg);
}
.fa-scale-110 {
margin-right: 0.8em;
}
.badge {
position: absolute;
right: 0.6rem;
top: 1.25em;
.nav-panel-heading {
// breaks without a unit
--panel-heading-height-padding: 0em;
}
}
</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'
trigger: String,
// Either 'top' or 'bottom'
// 'top', 'bottom', 'left', 'right'
placement: String,
// Takes object with properties 'x' and 'y', values of these can be
@ -84,6 +84,8 @@ const Popover = {
const anchorStyle = getComputedStyle(anchorEl)
const topPadding = parseFloat(anchorStyle.paddingTop)
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
const origin = {
@ -170,7 +172,7 @@ const Popover = {
if (overlayCenter) {
translateX = origin.x + horizOffset
translateY = origin.y + vertOffset
} else {
} else if (this.placement !== 'right' && this.placement !== 'left') {
// Default to whatever user wished with placement prop
let usingTop = this.placement !== 'bottom'
@ -189,6 +191,25 @@ const Popover = {
const xOffset = (this.offset && this.offset.x) || 0
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 = {

View file

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

View file

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

View file

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

View file

@ -2,6 +2,7 @@ import { mapState, mapGetters } from 'vuex'
import UserCard from '../user_card/user_card.vue'
import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils'
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 {
faSignInAlt,
@ -15,6 +16,7 @@ import {
faTachometerAlt,
faCog,
faInfoCircle,
faCompass,
faList
} from '@fortawesome/free-solid-svg-icons'
@ -30,6 +32,7 @@ library.add(
faTachometerAlt,
faCog,
faInfoCircle,
faCompass,
faList
)
@ -80,10 +83,16 @@ const SideDrawer = {
return this.$store.state.instance.federating
},
timelinesRoute () {
let name
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({
pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable

View file

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

View file

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

View file

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

View file

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

View file

@ -10,7 +10,19 @@
@close="() => isOpen = false"
>
<template #content>
<TimelineMenuContent />
<ListsMenuContent
v-if="useListsMenu"
:show-pin="false"
class="timelines"
/>
<ul v-else>
<NavigationEntry
v-for="item in timelinesList"
:key="item.name"
:show-pin="false"
:item="item"
/>
</ul>
</template>
<template #trigger>
<span class="button-unstyled title timeline-menu-title">
@ -138,8 +150,7 @@
background-color: $fallback--lightBg;
background-color: var(--selectedMenu, $fallback--lightBg);
color: $fallback--text;
color: var(--selectedMenuText, $fallback--text);
--faint: var(--selectedMenuFaintText, $fallback--faint);
color: var(--selectedMenuText, $fallback--text); --faint: var(--selectedMenuFaintText, $fallback--faint);
--faintLink: var(--selectedMenuFaintLink, $fallback--faint);
--lightText: var(--selectedMenuLightText, $fallback--lightText);
--icon: var(--selectedMenuIcon, $fallback--icon);

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

View file

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

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",
"verify": "Verify",
"close": "Close",
"undo": "Undo",
"yes": "Yes",
"no": "No",
"peek": "Peek",
"role": {
"admin": "Admin",
"moderator": "Moderator"
},
"unpin": "Unpin item",
"pin": "Pin item",
"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_fail": "Failed to load flash content, see console for details.",
@ -149,7 +154,10 @@
"preferences": "Preferences",
"timelines": "Timelines",
"chats": "Chats",
"lists": "Lists"
"lists": "Lists",
"edit_nav_mobile": "Customize navigation bar",
"edit_pinned": "Edit pinned items",
"edit_finish": "Done editing"
},
"notifications": {
"broken_favorite": "Unknown status, searching for it…",
@ -987,7 +995,18 @@
"create": "Create",
"save": "Save changes",
"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": {
"audio": "Audio",

View file

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

View file

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

View file

@ -9,27 +9,43 @@ export const mutations = {
setLists (state, value) {
state.allLists = value
},
setList (state, { id, title }) {
if (!state.allListsObject[id]) {
state.allListsObject[id] = {}
setList (state, { listId, title }) {
if (!state.allListsObject[listId]) {
state.allListsObject[listId] = { accountIds: [] }
}
state.allListsObject[id].title = title
state.allListsObject[listId].title = title
if (!find(state.allLists, { id })) {
state.allLists.push({ id, title })
const entry = find(state.allLists, { id: listId })
if (!entry) {
state.allLists.push({ id: listId, title })
} else {
find(state.allLists, { id }).title = title
entry.title = title
}
},
setListAccounts (state, { id, accountIds }) {
if (!state.allListsObject[id]) {
state.allListsObject[id] = {}
setListAccounts (state, { listId, accountIds }) {
if (!state.allListsObject[listId]) {
state.allListsObject[listId] = { accountIds: [] }
}
state.allListsObject[id].accountIds = accountIds
state.allListsObject[listId].accountIds = accountIds
},
deleteList (state, { id }) {
delete state.allListsObject[id]
remove(state.allLists, list => list.id === id)
addListAccount (state, { listId, accountId }) {
if (!state.allListsObject[listId]) {
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 }) {
return rootState.api.backendInteractor.createList({ title })
.then((list) => {
commit('setList', { id: list.id, title })
commit('setList', { listId: list.id, title })
return list
})
},
fetchList ({ rootState, commit }, { id }) {
return rootState.api.backendInteractor.getList({ id })
.then((list) => commit('setList', { id: list.id, title: list.title }))
fetchList ({ rootState, commit }, { listId }) {
return rootState.api.backendInteractor.getList({ listId })
.then((list) => commit('setList', { listId: list.id, title: list.title }))
},
fetchListAccounts ({ rootState, commit }, { id }) {
return rootState.api.backendInteractor.getListAccounts({ id })
.then((accountIds) => commit('setListAccounts', { id, accountIds }))
fetchListAccounts ({ rootState, commit }, { listId }) {
return rootState.api.backendInteractor.getListAccounts({ listId })
.then((accountIds) => commit('setListAccounts', { listId, accountIds }))
},
setList ({ rootState, commit }, { id, title }) {
rootState.api.backendInteractor.updateList({ id, title })
commit('setList', { id, title })
setList ({ rootState, commit }, { listId, title }) {
rootState.api.backendInteractor.updateList({ listId, title })
commit('setList', { listId, title })
},
setListAccounts ({ rootState, commit }, { id, accountIds }) {
const saved = rootState.lists.allListsObject[id].accountIds || []
setListAccounts ({ rootState, commit }, { listId, accountIds }) {
const saved = rootState.lists.allListsObject[listId].accountIds || []
const added = accountIds.filter(id => !saved.includes(id))
const removed = saved.filter(id => !accountIds.includes(id))
commit('setListAccounts', { id, accountIds })
commit('setListAccounts', { listId, accountIds })
if (added.length > 0) {
rootState.api.backendInteractor.addAccountsToList({ id, accountIds: added })
rootState.api.backendInteractor.addAccountsToList({ listId, accountIds: added })
}
if (removed.length > 0) {
rootState.api.backendInteractor.removeAccountsFromList({ id, accountIds: removed })
rootState.api.backendInteractor.removeAccountsFromList({ listId, accountIds: removed })
}
},
deleteList ({ rootState, commit }, { id }) {
rootState.api.backendInteractor.deleteList({ id })
commit('deleteList', { id })
addListAccount ({ rootState, commit }, { listId, accountId }) {
return rootState
.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 { 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'
export const VERSION = 1
@ -14,14 +14,21 @@ export const defaultState = {
// storage of flags - stuff that can only be set and incremented
flagStorage: {
updateCounter: 0, // Counter for most recent update notification seen
// TODO move to prefsStorage when that becomes a thing since only way
// this can be reset is by complete reset of all flags
dontShowUpdateNotifs: 0, // if user chose to not show update notifications ever again
reset: 0 // special flag that can be used to force-reset all flags, debug purposes only
// special reset codes:
// 1000: trim keys to those known by currently running FE
// 1001: same as above + reset everything to 0
},
prefsStorage: {
_journal: [],
simple: {
dontShowUpdateNotifs: false,
collapseNav: false
},
collections: {
pinnedNavItems: ['home', 'dms', 'chats']
}
},
// raw data
raw: null,
// local cache
@ -33,14 +40,43 @@ export const newUserFlags = {
updateCounter: CURRENT_UPDATE_COUNTER // new users don't need to see update notification
}
const _wrapData = (data) => ({
export const _moveItemInArray = (array, value, movement) => {
const oldIndex = array.indexOf(value)
const newIndex = oldIndex + movement
const newArray = [...array]
// remove old
newArray.splice(oldIndex, 1)
// add new
newArray.splice(clamp(newIndex, 0, newArray.length + 1), 0, value)
return newArray
}
const _wrapData = (data, userName) => ({
...data,
_user: userName,
_timestamp: Date.now(),
_version: VERSION
})
const _checkValidity = (data) => data._timestamp > 0 && data._version > 0
const _verifyPrefs = (state) => {
state.prefsStorage = state.prefsStorage || {
simple: {},
collections: {}
}
Object.entries(defaultState.prefsStorage.simple).forEach(([k, v]) => {
if (typeof v === 'number' || typeof v === 'boolean') return
console.warn(`Preference simple.${k} as invalid type, reinitializing`)
set(state.prefsStorage.simple, k, defaultState.prefsStorage.simple[k])
})
Object.entries(defaultState.prefsStorage.collections).forEach(([k, v]) => {
if (Array.isArray(v)) return
console.warn(`Preference collections.${k} as invalid type, reinitializing`)
set(state.prefsStorage.collections, k, defaultState.prefsStorage.collections[k])
})
}
export const _getRecentData = (cache, live) => {
const result = { recent: null, stale: null, needUpload: false }
const cacheValid = _checkValidity(cache || {})
@ -85,6 +121,8 @@ export const _getAllFlags = (recent, stale) => {
}
export const _mergeFlags = (recent, stale, allFlagKeys) => {
if (!stale.flagStorage) return recent.flagStorage
if (!recent.flagStorage) return stale.flagStorage
return Object.fromEntries(allFlagKeys.map(flag => {
const recentFlag = recent.flagStorage[flag]
const staleFlag = stale.flagStorage[flag]
@ -93,6 +131,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) => {
let result = { ...totalFlags }
const allFlagKeys = Object.keys(totalFlags)
@ -149,10 +269,17 @@ export const _doMigrations = (cache) => {
}
export const mutations = {
clearServerSideStorage (state, userData) {
state = { ...cloneDeep(defaultState) }
},
setServerSideStorage (state, userData) {
const live = userData.storage
state.raw = live
let cache = state.cache
if (cache && cache._user !== userData.fqn) {
console.warn('cache belongs to another user! reinitializing local cache!')
cache = null
}
cache = _doMigrations(cache)
@ -165,7 +292,8 @@ export const mutations = {
if (recent === null) {
console.debug(`Data is empty, initializing for ${userNew ? 'new' : 'existing'} user`)
recent = _wrapData({
flagStorage: { ...flagsTemplate }
flagStorage: { ...flagsTemplate },
prefsStorage: { ...defaultState.prefsStorage }
})
}
@ -180,17 +308,23 @@ export const mutations = {
const allFlagKeys = _getAllFlags(recent, stale)
let totalFlags
let totalPrefs
if (dirty) {
// Merge the flags
console.debug('Merging the flags...')
console.debug('Merging the data...')
totalFlags = _mergeFlags(recent, stale, allFlagKeys)
_verifyPrefs(recent)
_verifyPrefs(stale)
totalPrefs = _mergePrefs(recent.prefsStorage, stale.prefsStorage)
} else {
totalFlags = recent.flagStorage
totalPrefs = recent.prefsStorage
}
totalFlags = _resetFlags(totalFlags)
recent.flagStorage = totalFlags
recent.flagStorage = { ...flagsTemplate, ...totalFlags }
recent.prefsStorage = { ...defaultState.prefsStorage, ...totalPrefs }
state.dirty = dirty || needsUpload
state.cache = recent
@ -199,10 +333,72 @@ export const mutations = {
state.cache._timestamp = Math.min(stale._timestamp, recent._timestamp)
}
state.flagStorage = state.cache.flagStorage
state.prefsStorage = state.cache.prefsStorage
},
setFlag (state, { flag, value }) {
state.flagStorage[flag] = value
state.dirty = true
},
setPreference (state, { path, value }) {
if (path.startsWith('_')) {
console.error(`tried to edit internal (starts with _) field '${path}', ignoring.`)
return
}
set(state.prefsStorage, path, value)
state.prefsStorage._journal = [
...state.prefsStorage._journal,
{ operation: 'set', path, args: [value], timestamp: Date.now() }
]
state.dirty = true
},
addCollectionPreference (state, { path, value }) {
if (path.startsWith('_')) {
console.error(`tried to edit internal (starts with _) field '${path}', ignoring.`)
return
}
const collection = new Set(get(state.prefsStorage, path))
collection.add(value)
set(state.prefsStorage, path, [...collection])
state.prefsStorage._journal = [
...state.prefsStorage._journal,
{ operation: 'addToCollection', path, args: [value], timestamp: Date.now() }
]
state.dirty = true
},
removeCollectionPreference (state, { path, value }) {
if (path.startsWith('_')) {
console.error(`tried to edit internal (starts with _) field '${path}', ignoring.`)
return
}
const collection = new Set(get(state.prefsStorage, path))
collection.delete(value)
set(state.prefsStorage, path, [...collection])
state.prefsStorage._journal = [
...state.prefsStorage._journal,
{ operation: 'removeFromCollection', path, args: [value], timestamp: Date.now() }
]
state.dirty = true
},
reorderCollectionPreference (state, { path, value, movement }) {
if (path.startsWith('_')) {
console.error(`tried to edit internal (starts with _) field '${path}', ignoring.`)
return
}
const collection = get(state.prefsStorage, path)
const newCollection = _moveItemInArray(collection, value, movement)
set(state.prefsStorage, path, newCollection)
state.prefsStorage._journal = [
...state.prefsStorage._journal,
{ operation: 'arrangeCollection', path, args: [value], timestamp: Date.now() }
]
state.dirty = true
},
updateCache (state, { username }) {
state.prefsStorage._journal = _mergeJournal(state.prefsStorage._journal)
state.cache = _wrapData({
flagStorage: toRaw(state.flagStorage),
prefsStorage: toRaw(state.prefsStorage)
}, username)
}
}
@ -214,15 +410,16 @@ const serverSideStorage = {
actions: {
pushServerSideStorage ({ state, rootState, commit }, { force = false } = {}) {
const needPush = state.dirty || force
console.log(needPush)
if (!needPush) return
state.cache = _wrapData({
flagStorage: toRaw(state.flagStorage)
})
commit('updateCache', { username: rootState.users.currentUser.fqn })
const params = { pleroma_settings_store: { 'pleroma-fe': state.cache } }
rootState.api.backendInteractor
.updateProfile({ params })
.then((user) => commit('setServerSideStorage', user))
state.dirty = false
.then((user) => {
commit('setServerSideStorage', user)
state.dirty = false
})
}
}
}

View file

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

View file

@ -46,7 +46,7 @@
.panel-footer {
--panel-heading-height-padding: 0.6em;
--__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;
box-sizing: border-box;
@ -57,7 +57,7 @@
grid-column-gap: 0.5em;
flex: none;
background-size: cover;
padding: 0.6em;
padding: var(--panel-heading-height-padding);
height: var(--__panel-heading-height);
line-height: var(--__panel-heading-height-inner);
z-index: 4;
@ -147,6 +147,15 @@
color: var(--panelLink, $fallback--link);
}
.button-unstyled:hover,
a:hover {
i[class*=icon-],
.svg-inline--fa,
.iconLetter {
color: var(--panelText);
}
}
.faint {
background-color: transparent;
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_RELATIONSHIPS_URL = '/api/v1/accounts/relationships'
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_TIMELINE_URL = id => `/api/v1/timelines/list/${id}`
const MASTODON_LIST_ACCOUNTS_URL = id => `/api/v1/lists/${id}/accounts`
@ -263,6 +264,13 @@ const unfollowUser = ({ id, credentials }) => {
}).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 }) => {
return promisedRequest({ url: MASTODON_PIN_OWN_STATUS(id), credentials, method: 'POST' })
.then((data) => parseStatus(data))
@ -428,14 +436,14 @@ const createList = ({ title, credentials }) => {
}).then((data) => data.json())
}
const getList = ({ id, credentials }) => {
const url = MASTODON_LIST_URL(id)
const getList = ({ listId, credentials }) => {
const url = MASTODON_LIST_URL(listId)
return fetch(url, { headers: authHeaders(credentials) })
.then((data) => data.json())
}
const updateList = ({ id, title, credentials }) => {
const url = MASTODON_LIST_URL(id)
const updateList = ({ listId, title, credentials }) => {
const url = MASTODON_LIST_URL(listId)
const headers = authHeaders(credentials)
headers['Content-Type'] = 'application/json'
@ -446,15 +454,15 @@ const updateList = ({ id, title, credentials }) => {
})
}
const getListAccounts = ({ id, credentials }) => {
const url = MASTODON_LIST_ACCOUNTS_URL(id)
const getListAccounts = ({ listId, credentials }) => {
const url = MASTODON_LIST_ACCOUNTS_URL(listId)
return fetch(url, { headers: authHeaders(credentials) })
.then((data) => data.json())
.then((data) => data.map(({ id }) => id))
}
const addAccountsToList = ({ id, accountIds, credentials }) => {
const url = MASTODON_LIST_ACCOUNTS_URL(id)
const addAccountsToList = ({ listId, accountIds, credentials }) => {
const url = MASTODON_LIST_ACCOUNTS_URL(listId)
const headers = authHeaders(credentials)
headers['Content-Type'] = 'application/json'
@ -465,8 +473,8 @@ const addAccountsToList = ({ id, accountIds, credentials }) => {
})
}
const removeAccountsFromList = ({ id, accountIds, credentials }) => {
const url = MASTODON_LIST_ACCOUNTS_URL(id)
const removeAccountsFromList = ({ listId, accountIds, credentials }) => {
const url = MASTODON_LIST_ACCOUNTS_URL(listId)
const headers = authHeaders(credentials)
headers['Content-Type'] = 'application/json'
@ -477,8 +485,8 @@ const removeAccountsFromList = ({ id, accountIds, credentials }) => {
})
}
const deleteList = ({ id, credentials }) => {
const url = MASTODON_LIST_URL(id)
const deleteList = ({ listId, credentials }) => {
const url = MASTODON_LIST_URL(listId)
return fetch(url, {
method: 'DELETE',
headers: authHeaders(credentials)
@ -1584,7 +1592,8 @@ const apiService = {
sendChatMessage,
readChat,
deleteChatMessage,
setReportState
setReportState,
fetchUserInLists
}
export default apiService

View file

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

View file

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

View file

@ -4,9 +4,11 @@ import {
VERSION,
COMMAND_TRIM_FLAGS,
COMMAND_TRIM_FLAGS_AND_RESET,
_moveItemInArray,
_getRecentData,
_getAllFlags,
_mergeFlags,
_mergePrefs,
_resetFlags,
mutations,
defaultState,
@ -28,6 +30,7 @@ describe('The serverSideStorage module', () => {
expect(state.cache._version).to.eql(VERSION)
expect(state.cache._timestamp).to.be.a('number')
expect(state.cache.flagStorage).to.eql(defaultState.flagStorage)
expect(state.cache.prefsStorage).to.eql(defaultState.prefsStorage)
})
it('should initialize storage with proper flags for new users if none present', () => {
@ -36,6 +39,7 @@ describe('The serverSideStorage module', () => {
expect(state.cache._version).to.eql(VERSION)
expect(state.cache._timestamp).to.be.a('number')
expect(state.cache.flagStorage).to.eql(newUserFlags)
expect(state.cache.prefsStorage).to.eql(defaultState.prefsStorage)
})
it('should merge flags even if remote timestamp is older', () => {
@ -57,6 +61,9 @@ describe('The serverSideStorage module', () => {
flagStorage: {
...defaultState.flagStorage,
updateCounter: 1
},
prefsStorage: {
...defaultState.prefsStorage
}
}
}
@ -99,9 +106,62 @@ describe('The serverSideStorage module', () => {
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('_moveItemInArray', () => {
it('should move item according to movement value', () => {
expect(_moveItemInArray([1, 2, 3, 4], 4, -1)).to.eql([1, 2, 4, 3])
expect(_moveItemInArray([1, 2, 3, 4], 1, 2)).to.eql([2, 3, 1, 4])
})
it('should clamp movement to within array', () => {
expect(_moveItemInArray([1, 2, 3, 4], 4, -10)).to.eql([4, 1, 2, 3])
expect(_moveItemInArray([1, 2, 3, 4], 3, 99)).to.eql([1, 2, 4, 3])
})
})
describe('_getRecentData', () => {
it('should handle nulls correctly', () => {
expect(_getRecentData(null, null)).to.eql({ recent: null, stale: null, needUpload: true })
@ -157,6 +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', () => {
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 }