Merge remote-tracking branch 'origin/develop' into scrolltotop

* origin/develop: (59 commits)
  a11y
  Use dedicated indicator for non-ascii domain names
  add a favorites "timeline" shortcut
  refactor navigation-entry and use them in other nav items
  Update dependency sinon-chai to v3
  Update dependency semver to v7
  Update dependency vue-router to v4.1.5
  Update dependency eslint to v8.23.0
  Update dependency vue-template-compiler to v2.7.10
  Update dependency @vue/babel-helper-vue-jsx-merge-props to v1.4.0
  Update dependency eslint-plugin-promise to v6.0.1
  fix lists edit page
  change ugly checkbox to a list element that doesn't look too much out of place
  a11y
  squeeze/stretch pinned items as long as there's enough space for it, hide items that won't fitc
  Remove isparta
  lint
  fix being unable to edit timeline pins on mobile
  aria
  fix mobile side drawer causing issues
  ...
This commit is contained in:
Henry Jameson 2022-08-30 23:54:16 +03:00
commit 887fac5add
80 changed files with 1974 additions and 1308 deletions

View File

@ -43,19 +43,19 @@
"utf8": "3.0.0", "utf8": "3.0.0",
"vue": "3.2.37", "vue": "3.2.37",
"vue-i18n": "9.2.2", "vue-i18n": "9.2.2",
"vue-router": "4.1.3", "vue-router": "4.1.5",
"vue-template-compiler": "2.7.9", "vue-template-compiler": "2.7.10",
"vuex": "4.0.2" "vuex": "4.0.2"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.18.10", "@babel/core": "7.18.13",
"@babel/eslint-parser": "7.18.9", "@babel/eslint-parser": "7.18.9",
"@babel/plugin-transform-runtime": "7.18.10", "@babel/plugin-transform-runtime": "7.18.10",
"@babel/preset-env": "7.18.10", "@babel/preset-env": "7.18.10",
"@babel/register": "7.18.9", "@babel/register": "7.18.9",
"@intlify/vue-i18n-loader": "5.0.0", "@intlify/vue-i18n-loader": "5.0.0",
"@ungap/event-target": "0.2.3", "@ungap/event-target": "0.2.3",
"@vue/babel-helper-vue-jsx-merge-props": "1.2.1", "@vue/babel-helper-vue-jsx-merge-props": "1.4.0",
"@vue/babel-plugin-jsx": "1.1.1", "@vue/babel-plugin-jsx": "1.1.1",
"@vue/compiler-sfc": "3.2.37", "@vue/compiler-sfc": "3.2.37",
"@vue/test-utils": "2.0.2", "@vue/test-utils": "2.0.2",
@ -71,12 +71,12 @@
"css-loader": "6.7.1", "css-loader": "6.7.1",
"css-minimizer-webpack-plugin": "4.0.0", "css-minimizer-webpack-plugin": "4.0.0",
"custom-event-polyfill": "1.0.7", "custom-event-polyfill": "1.0.7",
"eslint": "8.22.0", "eslint": "8.23.0",
"eslint-config-standard": "17.0.0", "eslint-config-standard": "17.0.0",
"eslint-formatter-friendly": "7.0.0", "eslint-formatter-friendly": "7.0.0",
"eslint-plugin-import": "2.26.0", "eslint-plugin-import": "2.26.0",
"eslint-plugin-n": "15.2.5", "eslint-plugin-n": "15.2.5",
"eslint-plugin-promise": "6.0.0", "eslint-plugin-promise": "6.0.1",
"eslint-plugin-vue": "9.4.0", "eslint-plugin-vue": "9.4.0",
"eslint-webpack-plugin": "3.2.0", "eslint-webpack-plugin": "3.2.0",
"eventsource-polyfill": "0.9.6", "eventsource-polyfill": "0.9.6",
@ -85,7 +85,6 @@
"html-webpack-plugin": "5.5.0", "html-webpack-plugin": "5.5.0",
"http-proxy-middleware": "2.0.6", "http-proxy-middleware": "2.0.6",
"iso-639-1": "2.1.15", "iso-639-1": "2.1.15",
"isparta-loader": "2.0.0",
"json-loader": "0.5.7", "json-loader": "0.5.7",
"karma": "6.4.0", "karma": "6.4.0",
"karma-coverage": "2.2.0", "karma-coverage": "2.2.0",
@ -108,11 +107,11 @@
"sass": "1.54.5", "sass": "1.54.5",
"sass-loader": "13.0.2", "sass-loader": "13.0.2",
"selenium-server": "2.53.1", "selenium-server": "2.53.1",
"semver": "5.7.1", "semver": "7.3.7",
"serviceworker-webpack5-plugin": "2.0.0", "serviceworker-webpack5-plugin": "2.0.0",
"shelljs": "0.8.5", "shelljs": "0.8.5",
"sinon": "2.4.1", "sinon": "2.4.1",
"sinon-chai": "2.14.0", "sinon-chai": "3.7.0",
"stylelint": "13.13.1", "stylelint": "13.13.1",
"stylelint-config-standard": "20.0.0", "stylelint-config-standard": "20.0.0",
"stylelint-rscss": "0.4.0", "stylelint-rscss": "0.4.0",

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

@ -1,5 +1,6 @@
import UserPopover from '../user_popover/user_popover.vue' import UserPopover from '../user_popover/user_popover.vue'
import UserAvatar from '../user_avatar/user_avatar.vue' import UserAvatar from '../user_avatar/user_avatar.vue'
import UserLink from '../user_link/user_link.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx' import RichContent from 'src/components/rich_content/rich_content.jsx'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
@ -10,7 +11,8 @@ const BasicUserCard = {
components: { components: {
UserPopover, UserPopover,
UserAvatar, UserAvatar,
RichContent RichContent,
UserLink
}, },
methods: { methods: {
userProfileLink (user) { userProfileLink (user) {

View File

@ -30,12 +30,10 @@
/> />
</div> </div>
<div> <div>
<router-link <user-link
class="basic-user-card-screen-name" class="basic-user-card-screen-name"
:to="userProfileLink(user)" :user="user"
> />
@{{ user.screen_name_ui }}
</router-link>
</div> </div>
<slot /> <slot />
</div> </div>

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,6 @@
import Completion from '../../services/completion/completion.js' import Completion from '../../services/completion/completion.js'
import EmojiPicker from '../emoji_picker/emoji_picker.vue' import EmojiPicker from '../emoji_picker/emoji_picker.vue'
import UnicodeDomainIndicator from '../unicode_domain_indicator/unicode_domain_indicator.vue'
import { take } from 'lodash' import { take } from 'lodash'
import { findOffset } from '../../services/offset_finder/offset_finder.service.js' import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
@ -120,7 +121,8 @@ const EmojiInput = {
} }
}, },
components: { components: {
EmojiPicker EmojiPicker,
UnicodeDomainIndicator
}, },
computed: { computed: {
padEmoji () { padEmoji () {

View File

@ -50,7 +50,21 @@
<span v-else>{{ suggestion.replacement }}</span> <span v-else>{{ suggestion.replacement }}</span>
</span> </span>
<div class="label"> <div class="label">
<span class="displayText">{{ suggestion.displayText }}</span> <span
v-if="suggestion.user"
class="displayText"
>
{{ suggestion.displayText }}<UnicodeDomainIndicator
:user="suggestion.user"
:at="false"
/>
</span>
<span
v-if="!suggestion.user"
class="displayText"
>
{{ suggestion.displayText }}
</span>
<span class="detailText">{{ suggestion.detailText }}</span> <span class="detailText">{{ suggestion.detailText }}</span>
</div> </div>
</div> </div>

View File

@ -116,11 +116,12 @@ export const suggestUsers = ({ dispatch, state }) => {
return diff + nameAlphabetically + screenNameAlphabetically return diff + nameAlphabetically + screenNameAlphabetically
/* eslint-disable camelcase */ /* eslint-disable camelcase */
}).map(({ screen_name, screen_name_ui, name, profile_image_url_original }) => ({ }).map((user) => ({
displayText: screen_name_ui, user,
detailText: name, displayText: user.screen_name_ui,
imageUrl: profile_image_url_original, detailText: user.name,
replacement: '@' + screen_name + ' ' imageUrl: user.profile_image_url_original,
replacement: '@' + user.screen_name + ' '
})) }))
/* eslint-enable camelcase */ /* eslint-enable camelcase */

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 generateProfileLink from 'src/services/user_profile_link_generator/user_p
import { mapGetters, mapState } from 'vuex' import { mapGetters, mapState } from 'vuex'
import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js' import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
import UserAvatar from '../user_avatar/user_avatar.vue' import UserAvatar from '../user_avatar/user_avatar.vue'
import UnicodeDomainIndicator from '../unicode_domain_indicator/unicode_domain_indicator.vue'
import { defineAsyncComponent } from 'vue' import { defineAsyncComponent } from 'vue'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import { import {
@ -16,6 +17,7 @@ const MentionLink = {
name: 'MentionLink', name: 'MentionLink',
components: { components: {
UserAvatar, UserAvatar,
UnicodeDomainIndicator,
UserPopover: defineAsyncComponent(() => import('../user_popover/user_popover.vue')) UserPopover: defineAsyncComponent(() => import('../user_popover/user_popover.vue'))
}, },
props: { props: {

View File

@ -47,6 +47,9 @@
class="serverName" class="serverName"
:class="{ '-faded': shouldFadeDomain }" :class="{ '-faded': shouldFadeDomain }"
v-html="'@' + serverName" v-html="'@' + serverName"
/><UnicodeDomainIndicator
v-if="shouldShowFullUserName"
:user="user"
/> />
</span> </span>
<span <span

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

@ -5,6 +5,7 @@ import UserAvatar from '../user_avatar/user_avatar.vue'
import UserCard from '../user_card/user_card.vue' import UserCard from '../user_card/user_card.vue'
import Timeago from '../timeago/timeago.vue' import Timeago from '../timeago/timeago.vue'
import Report from '../report/report.vue' import Report from '../report/report.vue'
import UserLink from '../user_link/user_link.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx' import RichContent from 'src/components/rich_content/rich_content.jsx'
import UserPopover from '../user_popover/user_popover.vue' import UserPopover from '../user_popover/user_popover.vue'
import { isStatusNotification } from '../../services/notification_utils/notification_utils.js' import { isStatusNotification } from '../../services/notification_utils/notification_utils.js'
@ -50,7 +51,8 @@ const Notification = {
Status, Status,
Report, Report,
RichContent, RichContent,
UserPopover UserPopover,
UserLink
}, },
methods: { methods: {
toggleUserExpanded () { toggleUserExpanded () {

View File

@ -11,9 +11,10 @@
class="Notification container -muted" class="Notification container -muted"
> >
<small> <small>
<router-link :to="userProfileLink"> <user-link
{{ notification.from_profile.screen_name_ui }} :user="notification.from_profile"
</router-link> :at="false"
/>
</small> </small>
<button <button
class="button-unstyled unmute" class="button-unstyled unmute"
@ -174,12 +175,10 @@
v-if="notification.type === 'follow' || notification.type === 'follow_request'" v-if="notification.type === 'follow' || notification.type === 'follow_request'"
class="follow-text" class="follow-text"
> >
<router-link <user-link
:to="userProfileLink"
class="follow-name" class="follow-name"
> :user="notification.from_profile"
@{{ notification.from_profile.screen_name_ui }} />
</router-link>
<div <div
v-if="notification.type === 'follow_request'" v-if="notification.type === 'follow_request'"
style="white-space: nowrap;" style="white-space: nowrap;"
@ -210,9 +209,9 @@
v-else-if="notification.type === 'move'" v-else-if="notification.type === 'move'"
class="move-text" class="move-text"
> >
<router-link :to="targetUserProfileLink"> <user-link
@{{ notification.target.screen_name_ui }} :user="notification.target"
</router-link> />
</div> </div>
<Report <Report
v-else-if="notification.type === 'pleroma:report'" v-else-if="notification.type === 'pleroma:report'"

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

@ -13,6 +13,7 @@ import StatusPopover from '../status_popover/status_popover.vue'
import UserPopover from '../user_popover/user_popover.vue' import UserPopover from '../user_popover/user_popover.vue'
import UserListPopover from '../user_list_popover/user_list_popover.vue' import UserListPopover from '../user_list_popover/user_list_popover.vue'
import EmojiReactions from '../emoji_reactions/emoji_reactions.vue' import EmojiReactions from '../emoji_reactions/emoji_reactions.vue'
import UserLink from '../user_link/user_link.vue'
import MentionsLine from 'src/components/mentions_line/mentions_line.vue' import MentionsLine from 'src/components/mentions_line/mentions_line.vue'
import MentionLink from 'src/components/mention_link/mention_link.vue' import MentionLink from 'src/components/mention_link/mention_link.vue'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
@ -115,7 +116,8 @@ const Status = {
RichContent, RichContent,
MentionLink, MentionLink,
MentionsLine, MentionsLine,
UserPopover UserPopover,
UserLink
}, },
props: [ props: [
'statusoid', 'statusoid',

View File

@ -25,9 +25,10 @@
class="fa-scale-110 fa-old-padding repeat-icon" class="fa-scale-110 fa-old-padding repeat-icon"
icon="retweet" icon="retweet"
/> />
<router-link :to="userProfileLink"> <user-link
{{ status.user.screen_name_ui }} :user="status.user"
</router-link> :at="false"
/>
</small> </small>
<small <small
v-if="showReasonMutedThread" v-if="showReasonMutedThread"
@ -164,13 +165,12 @@
> >
{{ status.user.name }} {{ status.user.name }}
</h4> </h4>
<router-link <user-link
class="account-name" class="account-name"
:title="status.user.screen_name_ui" :title="status.user.screen_name_ui"
:to="userProfileLink" :user="status.user"
> :at="false"
{{ status.user.screen_name_ui }} />
</router-link>
<img <img
v-if="!!(status.user && status.user.favicon)" v-if="!!(status.user && status.user.favicon)"
class="status-favicon" class="status-favicon"

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="showScrollTop" v-if="showScrollTop"
class="button-unstyled scroll-to-top-button" class="button-unstyled scroll-to-top-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

@ -0,0 +1,26 @@
<template>
<FAIcon
v-if="user && user.screen_name_ui_contains_non_ascii"
icon="code"
:title="$t('unicode_domain_indicator.tooltip')"
/>
</template>
<script>
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faCode
} from '@fortawesome/free-solid-svg-icons'
library.add(
faCode
)
const UnicodeDomainIndicator = {
props: {
user: Object
}
}
export default UnicodeDomainIndicator
</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

@ -5,6 +5,7 @@ import FollowButton from '../follow_button/follow_button.vue'
import ModerationTools from '../moderation_tools/moderation_tools.vue' import ModerationTools from '../moderation_tools/moderation_tools.vue'
import AccountActions from '../account_actions/account_actions.vue' import AccountActions from '../account_actions/account_actions.vue'
import Select from '../select/select.vue' import Select from '../select/select.vue'
import UserLink from '../user_link/user_link.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx' import RichContent from 'src/components/rich_content/rich_content.jsx'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { mapGetters } from 'vuex' import { mapGetters } from 'vuex'
@ -134,7 +135,8 @@ export default {
ProgressButton, ProgressButton,
FollowButton, FollowButton,
Select, Select,
RichContent RichContent,
UserLink
}, },
methods: { methods: {
muteUser () { muteUser () {

View File

@ -106,13 +106,10 @@
</button> </button>
</div> </div>
<div class="bottom-line"> <div class="bottom-line">
<router-link <user-link
class="user-screen-name" class="user-screen-name"
:title="user.screen_name_ui" :user="user"
:to="userProfileLink(user)" />
>
@{{ user.screen_name_ui }}
</router-link>
<template v-if="!hideBio"> <template v-if="!hideBio">
<span <span
v-if="user.deactivated" v-if="user.deactivated"

View File

@ -0,0 +1,38 @@
<template>
<router-link
:title="user.screen_name_ui"
:to="userProfileLink(user)"
>
{{ at ? '@' : '' }}{{ user.screen_name_ui }}<UnicodeDomainIndicator
:user="user"
/>
</router-link>
</template>
<script>
import UnicodeDomainIndicator from '../unicode_domain_indicator/unicode_domain_indicator.vue'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
const UserLink = {
props: {
user: Object,
at: {
type: Boolean,
default: true
}
},
components: {
UnicodeDomainIndicator
},
methods: {
userProfileLink (user) {
return generateProfileLink(
user.id, user.screen_name,
this.$store.state.instance.restrictedNicknames
)
}
}
}
export default UserLink
</script>

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

@ -1,5 +1,6 @@
import { defineAsyncComponent } from 'vue' import { defineAsyncComponent } from 'vue'
import RichContent from 'src/components/rich_content/rich_content.jsx' import RichContent from 'src/components/rich_content/rich_content.jsx'
import UnicodeDomainIndicator from '../unicode_domain_indicator/unicode_domain_indicator.vue'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import { faCircleNotch } from '@fortawesome/free-solid-svg-icons' import { faCircleNotch } from '@fortawesome/free-solid-svg-icons'
@ -15,6 +16,7 @@ const UserListPopover = {
], ],
components: { components: {
RichContent, RichContent,
UnicodeDomainIndicator,
Popover: defineAsyncComponent(() => import('../popover/popover.vue')), Popover: defineAsyncComponent(() => import('../popover/popover.vue')),
UserAvatar: defineAsyncComponent(() => import('../user_avatar/user_avatar.vue')) UserAvatar: defineAsyncComponent(() => import('../user_avatar/user_avatar.vue'))
}, },

View File

@ -29,7 +29,7 @@
:emoji="user.emoji" :emoji="user.emoji"
/> />
<!-- eslint-enable vue/no-v-html --> <!-- eslint-enable vue/no-v-html -->
<span class="user-list-screen-name">{{ user.screen_name_ui }}</span> <span class="user-list-screen-name">{{ user.screen_name_ui }}</span><UnicodeDomainIndicator :user="user" />
</div> </div>
</div> </div>
</template> </template>

View File

@ -2,13 +2,15 @@ import Status from '../status/status.vue'
import List from '../list/list.vue' import List from '../list/list.vue'
import Checkbox from '../checkbox/checkbox.vue' import Checkbox from '../checkbox/checkbox.vue'
import Modal from '../modal/modal.vue' import Modal from '../modal/modal.vue'
import UserLink from '../user_link/user_link.vue'
const UserReportingModal = { const UserReportingModal = {
components: { components: {
Status, Status,
List, List,
Checkbox, Checkbox,
Modal Modal,
UserLink
}, },
data () { data () {
return { return {

View File

@ -5,9 +5,13 @@
> >
<div class="user-reporting-panel panel"> <div class="user-reporting-panel panel">
<div class="panel-heading"> <div class="panel-heading">
<div class="title"> <i18n-t
{{ $t('user_reporting.title', [user.screen_name_ui]) }} tag="div"
</div> keypath="user_reporting.title"
class="title"
>
<UserLink :user="user" />
</i18n-t>
</div> </div>
<div class="panel-body"> <div class="panel-body">
<div class="user-reporting-panel-left"> <div class="user-reporting-panel-left">

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",
@ -1006,5 +1025,8 @@
"update_changelog": "For more details on what's changed, see {theFullChangelog}.", "update_changelog": "For more details on what's changed, see {theFullChangelog}.",
"update_changelog_here": "the full changelog", "update_changelog_here": "the full changelog",
"art_by": "Art by {linkToArtist}" "art_by": "Art by {linkToArtist}"
},
"unicode_domain_indicator": {
"tooltip": "This domain contains non-ascii characters."
} }
} }

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
@ -214,12 +216,14 @@ export const parseUser = (data) => {
output.screen_name_ui = output.screen_name output.screen_name_ui = output.screen_name
if (output.screen_name && output.screen_name.includes('@')) { if (output.screen_name && output.screen_name.includes('@')) {
const parts = output.screen_name.split('@') const parts = output.screen_name.split('@')
let unicodeDomain = punycode.toUnicode(parts[1]) const unicodeDomain = punycode.toUnicode(parts[1])
if (unicodeDomain !== parts[1]) { if (unicodeDomain !== parts[1]) {
// Add some identifier so users can potentially spot spoofing attempts: // Add some identifier so users can potentially spot spoofing attempts:
// lain.com and xn--lin-6cd.com would appear identical otherwise. // lain.com and xn--lin-6cd.com would appear identical otherwise.
unicodeDomain = '🌏' + unicodeDomain output.screen_name_ui_contains_non_ascii = true
output.screen_name_ui = [parts[0], unicodeDomain].join('@') output.screen_name_ui = [parts[0], unicodeDomain].join('@')
} else {
output.screen_name_ui_contains_non_ascii = false
} }
} }

View File

@ -17,11 +17,6 @@ const webpackConfig = merge(baseConfig, {
rules: utils.styleLoaders() rules: utils.styleLoaders()
}, },
devtool: 'inline-source-map', devtool: 'inline-source-map',
// vue: {
// loaders: {
// js: 'isparta'
// }
// },
plugins: [ plugins: [
new webpack.DefinePlugin({ new webpack.DefinePlugin({
'process.env': require('../../config/test.env') 'process.env': require('../../config/test.env')
@ -37,22 +32,6 @@ const webpackConfig = merge(baseConfig, {
// no need for app entry during tests // no need for app entry during tests
delete webpackConfig.entry delete webpackConfig.entry
// make sure isparta loader is applied before eslint
// webpackConfig.module.preLoaders = webpackConfig.module.preLoaders || []
// webpackConfig.module.preLoaders.unshift({
// test: /\.js$/,
// loader: 'isparta',
// include: path.resolve(projectRoot, 'src')
// })
// // only apply babel for test files when using isparta
// webpackConfig.module.loaders.some(function (loader, i) {
// if (loader.loader === 'babel') {
// loader.include = path.resolve(projectRoot, 'test/unit')
// return true
// }
// })
module.exports = function (config) { module.exports = function (config) {
config.set({ config.set({
// to run in additional browsers: // to run in additional browsers:

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 }

View File

@ -269,7 +269,8 @@ describe('API Entities normalizer', () => {
it('converts IDN to unicode and marks it as internatonal', () => { it('converts IDN to unicode and marks it as internatonal', () => {
const user = makeMockUserMasto({ acct: 'lain@xn--lin-6cd.com' }) const user = makeMockUserMasto({ acct: 'lain@xn--lin-6cd.com' })
expect(parseUser(user)).to.have.property('screen_name_ui').that.equal('lain@🌏lаin.com') expect(parseUser(user)).to.have.property('screen_name_ui').that.equal('lain@lаin.com')
expect(parseUser(user)).to.have.property('screen_name_ui_contains_non_ascii').that.equal(true)
}) })
}) })

647
yarn.lock

File diff suppressed because it is too large Load Diff