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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,6 @@
import UserPopover from '../user_popover/user_popover.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 generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
@ -10,7 +11,8 @@ const BasicUserCard = {
components: {
UserPopover,
UserAvatar,
RichContent
RichContent,
UserLink
},
methods: {
userProfileLink (user) {

View file

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

View file

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

View file

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

View file

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

View file

@ -50,7 +50,21 @@
<span v-else>{{ suggestion.replacement }}</span>
</span>
<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>
</div>
</div>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,18 @@
export const filterNavigation = (list = [], { hasChats, isFederating, isPrivate, currentUser }) => {
return list.filter(({ criteria, anon, anonRoute }) => {
const set = new Set(criteria || [])
if (!isFederating && set.has('federating')) return false
if (isPrivate && set.has('!private')) return false
if (!currentUser && !(anon || anonRoute)) return false
if ((!currentUser || !currentUser.locked) && set.has('lockedUser')) return false
if (!hasChats && set.has('chats')) return false
return true
})
}
export const getListEntries = state => state.lists.allLists.map(list => ({
name: 'list-' + list.id,
routeObject: { name: 'lists-timeline', params: { id: list.id } },
labelRaw: list.title,
iconLetter: list.title[0]
}))

View file

@ -0,0 +1,75 @@
export const USERNAME_ROUTES = new Set([
'bookmarks',
'dms',
'interactions',
'notifications',
'chat',
'chats',
'user-profile'
])
export const TIMELINES = {
home: {
route: 'friends',
icon: 'home',
label: 'nav.home_timeline',
criteria: ['!private']
},
public: {
route: 'public-timeline',
anon: true,
icon: 'users',
label: 'nav.public_tl',
criteria: ['!private']
},
twkn: {
route: 'public-external-timeline',
anon: true,
icon: 'globe',
label: 'nav.twkn',
criteria: ['!private', 'federating']
},
bookmarks: {
route: 'bookmarks',
icon: 'bookmark',
label: 'nav.bookmarks'
},
favorites: {
routeObject: { name: 'user-profile', query: { tab: 'favorites' } },
icon: 'star',
label: 'user_card.favorites'
},
dms: {
route: 'dms',
icon: 'envelope',
label: 'nav.dms'
}
}
export const ROOT_ITEMS = {
interactions: {
route: 'interactions',
icon: 'bell',
label: 'nav.interactions'
},
chats: {
route: 'chats',
icon: 'comments',
label: 'nav.chats',
badgeGetter: 'unreadChatCount',
criteria: ['chats']
},
friendRequests: {
route: 'friend-requests',
icon: 'user-plus',
label: 'nav.friend_requests',
criteria: ['lockedUser'],
badgeGetter: 'followRequestCount'
},
about: {
route: 'about',
anon: true,
icon: 'info-circle',
label: 'nav.about'
}
}

View file

@ -0,0 +1,47 @@
import { mapState } from 'vuex'
import { USERNAME_ROUTES } from 'src/components/navigation/navigation.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faThumbtack } from '@fortawesome/free-solid-svg-icons'
library.add(faThumbtack)
const NavigationEntry = {
props: ['item', 'showPin'],
methods: {
isPinned (value) {
return this.pinnedItems.has(value)
},
togglePin (value) {
if (this.isPinned(value)) {
this.$store.commit('removeCollectionPreference', { path: 'collections.pinnedNavItems', value })
} else {
this.$store.commit('addCollectionPreference', { path: 'collections.pinnedNavItems', value })
}
this.$store.dispatch('pushServerSideStorage')
}
},
computed: {
routeTo () {
if (!this.item.route && !this.item.routeObject) return null
let route
if (this.item.routeObject) {
route = this.item.routeObject
} else {
route = { name: (this.item.anon || this.currentUser) ? this.item.route : this.item.anonRoute }
}
if (USERNAME_ROUTES.has(route.name)) {
route.params = { username: this.currentUser.screen_name, name: this.currentUser.screen_name }
}
return route
},
getters () {
return this.$store.getters
},
...mapState({
currentUser: state => state.users.currentUser,
pinnedItems: state => new Set(state.serverSideStorage.prefsStorage.collections.pinnedNavItems)
})
}
}
export default NavigationEntry

View file

@ -0,0 +1,120 @@
<template>
<li class="NavigationEntry">
<component
:is="routeTo ? 'router-link' : 'button'"
class="menu-item button-unstyled"
:to="routeTo"
>
<span>
<FAIcon
v-if="item.icon"
fixed-width
class="fa-scale-110 menu-icon"
:icon="item.icon"
/>
</span>
<span
v-if="item.iconLetter"
class="icon iconLetter fa-scale-110 menu-icon"
>{{ item.iconLetter }}
</span>
<span class="label">
{{ item.labelRaw || $t(item.label) }}
</span>
<slot />
<div
v-if="item.badgeGetter && getters[item.badgeGetter]"
class="badge badge-notification"
>
{{ getters[item.badgeGetter] }}
</div>
<button
v-if="showPin && currentUser"
type="button"
class="button-unstyled extra-button"
:title="$t(isPinned ? 'general.unpin' : 'general.pin' )"
:aria-pressed="!!isPinned"
@click.stop.prevent="togglePin(item.name)"
>
<FAIcon
v-if="showPin && currentUser"
fixed-width
class="fa-scale-110"
:class="{ 'veryfaint': !isPinned(item.name) }"
:transform="!isPinned(item.name) ? 'rotate-45' : ''"
icon="thumbtack"
/>
</button>
</component>
</li>
</template>
<script src="./navigation_entry.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.NavigationEntry {
.label {
flex: 1;
}
.menu-icon {
margin-right: 0.8em;
}
.extra-button {
width: 3em;
text-align: center;
&:last-child {
margin-right: -0.8em;
}
}
.menu-item {
display: flex;
box-sizing: border-box;
align-items: baseline;
height: 3.5em;
line-height: 3.5em;
padding: 0 1em;
width: 100%;
color: $fallback--link;
color: var(--link, $fallback--link);
&:hover {
background-color: $fallback--lightBg;
background-color: var(--selectedMenu, $fallback--lightBg);
color: $fallback--link;
color: var(--selectedMenuText, $fallback--link);
--faint: var(--selectedMenuFaintText, $fallback--faint);
--faintLink: var(--selectedMenuFaintLink, $fallback--faint);
--lightText: var(--selectedMenuLightText, $fallback--lightText);
.menu-icon {
--icon: var(--text, $fallback--icon);
}
}
&.router-link-active {
font-weight: bolder;
background-color: $fallback--lightBg;
background-color: var(--selectedMenu, $fallback--lightBg);
color: $fallback--text;
color: var(--selectedMenuText, $fallback--text);
--faint: var(--selectedMenuFaintText, $fallback--faint);
--faintLink: var(--selectedMenuFaintLink, $fallback--faint);
--lightText: var(--selectedMenuLightText, $fallback--lightText);
.menu-icon {
--icon: var(--text, $fallback--icon);
}
&:hover {
text-decoration: underline;
}
}
}
}
</style>

View file

@ -0,0 +1,88 @@
import { mapState } from 'vuex'
import { TIMELINES, ROOT_ITEMS, USERNAME_ROUTES } from 'src/components/navigation/navigation.js'
import { getListEntries, filterNavigation } from 'src/components/navigation/filter.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faUsers,
faGlobe,
faBookmark,
faEnvelope,
faComments,
faBell,
faInfoCircle,
faStream,
faList
} from '@fortawesome/free-solid-svg-icons'
library.add(
faUsers,
faGlobe,
faBookmark,
faEnvelope,
faComments,
faBell,
faInfoCircle,
faStream,
faList
)
const NavPanel = {
props: ['limit'],
methods: {
getRouteTo (item) {
if (item.routeObject) {
return item.routeObject
}
const route = { name: (item.anon || this.currentUser) ? item.route : item.anonRoute }
if (USERNAME_ROUTES.has(route.name)) {
route.params = { username: this.currentUser.screen_name }
}
return route
}
},
computed: {
getters () {
return this.$store.getters
},
...mapState({
lists: getListEntries,
currentUser: state => state.users.currentUser,
followRequestCount: state => state.api.followRequests.length,
privateMode: state => state.instance.private,
federating: state => state.instance.federating,
pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable,
pinnedItems: state => new Set(state.serverSideStorage.prefsStorage.collections.pinnedNavItems)
}),
pinnedList () {
if (!this.currentUser) {
return [
{ ...TIMELINES.public, name: 'public' },
{ ...TIMELINES.twkn, name: 'twkn' },
{ ...ROOT_ITEMS.about, name: 'about' }
]
}
return filterNavigation(
[
...Object
.entries({ ...TIMELINES })
.filter(([k]) => this.pinnedItems.has(k))
.map(([k, v]) => ({ ...v, name: k })),
...this.lists.filter((k) => this.pinnedItems.has(k.name)),
...Object
.entries({ ...ROOT_ITEMS })
.filter(([k]) => this.pinnedItems.has(k))
.map(([k, v]) => ({ ...v, name: k }))
],
{
hasChats: this.pleromaChatMessagesAvailable,
isFederating: this.federating,
isPrivate: this.privateMode,
currentUser: this.currentUser
}
).slice(0, this.limit)
}
}
}
export default NavPanel

View file

@ -0,0 +1,76 @@
<template>
<span class="NavigationPins">
<router-link
v-for="item in pinnedList"
:key="item.name"
class="pinned-item"
:to="getRouteTo(item)"
:title="item.labelRaw || $t(item.label)"
>
<FAIcon
v-if="item.icon"
fixed-width
:icon="item.icon"
/>
<span
v-if="item.iconLetter"
class="iconLetter fa-scale-110 fa-old-padding"
>{{ item.iconLetter }}</span>
<div
v-if="item.badgeGetter && getters[item.badgeGetter]"
class="alert-dot"
/>
</router-link>
</span>
</template>
<script src="./navigation_pins.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.NavigationPins {
display: flex;
flex-wrap: wrap;
overflow: hidden;
height: 100%;
.alert-dot {
border-radius: 100%;
height: 0.5em;
width: 0.5em;
position: absolute;
right: calc(50% - 0.25em);
top: calc(50% - 0.25em);
margin-left: 6px;
margin-top: -6px;
background-color: $fallback--cRed;
background-color: var(--badgeNotification, $fallback--cRed);
}
.pinned-item {
position: relative;
flex: 1 0 3em;
min-width: 2em;
text-align: center;
overflow: visible;
box-sizing: border-box;
height: 100%;
& .svg-inline--fa,
& .iconLetter {
margin: 0;
}
&.router-link-active {
color: $fallback--text;
color: var(--selectedMenuText, $fallback--text);
border-bottom: 4px solid;
& .svg-inline--fa,
& .iconLetter {
color: inherit;
}
}
}
}
</style>

View file

@ -5,6 +5,7 @@ import UserAvatar from '../user_avatar/user_avatar.vue'
import UserCard from '../user_card/user_card.vue'
import Timeago from '../timeago/timeago.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 UserPopover from '../user_popover/user_popover.vue'
import { isStatusNotification } from '../../services/notification_utils/notification_utils.js'
@ -50,7 +51,8 @@ const Notification = {
Status,
Report,
RichContent,
UserPopover
UserPopover,
UserLink
},
methods: {
toggleUserExpanded () {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,29 +0,0 @@
import { mapState } from 'vuex'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faUsers,
faGlobe,
faBookmark,
faEnvelope,
faHome
} from '@fortawesome/free-solid-svg-icons'
library.add(
faUsers,
faGlobe,
faBookmark,
faEnvelope,
faHome
)
const TimelineMenuContent = {
computed: {
...mapState({
currentUser: state => state.users.currentUser,
privateMode: state => state.instance.private,
federating: state => state.instance.federating
})
}
}
export default TimelineMenuContent

View file

@ -1,66 +0,0 @@
<template>
<ul>
<li v-if="currentUser">
<router-link
class="menu-item"
:to="{ name: 'friends' }"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding "
icon="home"
/>{{ $t("nav.home_timeline") }}
</router-link>
</li>
<li v-if="currentUser || !privateMode">
<router-link
class="menu-item"
:to="{ name: 'public-timeline' }"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding "
icon="users"
/>{{ $t("nav.public_tl") }}
</router-link>
</li>
<li v-if="federating && (currentUser || !privateMode)">
<router-link
class="menu-item"
:to="{ name: 'public-external-timeline' }"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding "
icon="globe"
/>{{ $t("nav.twkn") }}
</router-link>
</li>
<li v-if="currentUser">
<router-link
class="menu-item"
:to="{ name: 'bookmarks'}"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding "
icon="bookmark"
/>{{ $t("nav.bookmarks") }}
</router-link>
</li>
<li v-if="currentUser">
<router-link
class="menu-item"
:to="{ name: 'dms', params: { username: currentUser.screen_name } }"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding "
icon="envelope"
/>{{ $t("nav.dms") }}
</router-link>
</li>
</ul>
</template>
<script src="./timeline_menu_content.js"></script>

View file

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

View file

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

View file

@ -5,6 +5,7 @@ import FollowButton from '../follow_button/follow_button.vue'
import ModerationTools from '../moderation_tools/moderation_tools.vue'
import AccountActions from '../account_actions/account_actions.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 generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { mapGetters } from 'vuex'
@ -134,7 +135,8 @@ export default {
ProgressButton,
FollowButton,
Select,
RichContent
RichContent,
UserLink
},
methods: {
muteUser () {

View file

@ -106,13 +106,10 @@
</button>
</div>
<div class="bottom-line">
<router-link
<user-link
class="user-screen-name"
:title="user.screen_name_ui"
:to="userProfileLink(user)"
>
@{{ user.screen_name_ui }}
</router-link>
:user="user"
/>
<template v-if="!hideBio">
<span
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 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 { faCircleNotch } from '@fortawesome/free-solid-svg-icons'
@ -15,6 +16,7 @@ const UserListPopover = {
],
components: {
RichContent,
UnicodeDomainIndicator,
Popover: defineAsyncComponent(() => import('../popover/popover.vue')),
UserAvatar: defineAsyncComponent(() => import('../user_avatar/user_avatar.vue'))
},

View file

@ -29,7 +29,7 @@
:emoji="user.emoji"
/>
<!-- 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>
</template>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
import { toRaw } from 'vue'
import { isEqual, cloneDeep } from 'lodash'
import { isEqual, cloneDeep, set, get, clamp, flatten, groupBy, findLastIndex, takeRight } from 'lodash'
import { CURRENT_UPDATE_COUNTER } from 'src/components/update_notification/update_notification.js'
export const VERSION = 1
@ -14,14 +14,21 @@ export const defaultState = {
// storage of flags - stuff that can only be set and incremented
flagStorage: {
updateCounter: 0, // Counter for most recent update notification seen
// TODO move to prefsStorage when that becomes a thing since only way
// this can be reset is by complete reset of all flags
dontShowUpdateNotifs: 0, // if user chose to not show update notifications ever again
reset: 0 // special flag that can be used to force-reset all flags, debug purposes only
// special reset codes:
// 1000: trim keys to those known by currently running FE
// 1001: same as above + reset everything to 0
},
prefsStorage: {
_journal: [],
simple: {
dontShowUpdateNotifs: false,
collapseNav: false
},
collections: {
pinnedNavItems: ['home', 'dms', 'chats']
}
},
// raw data
raw: null,
// local cache
@ -33,14 +40,43 @@ export const newUserFlags = {
updateCounter: CURRENT_UPDATE_COUNTER // new users don't need to see update notification
}
const _wrapData = (data) => ({
export const _moveItemInArray = (array, value, movement) => {
const oldIndex = array.indexOf(value)
const newIndex = oldIndex + movement
const newArray = [...array]
// remove old
newArray.splice(oldIndex, 1)
// add new
newArray.splice(clamp(newIndex, 0, newArray.length + 1), 0, value)
return newArray
}
const _wrapData = (data, userName) => ({
...data,
_user: userName,
_timestamp: Date.now(),
_version: VERSION
})
const _checkValidity = (data) => data._timestamp > 0 && data._version > 0
const _verifyPrefs = (state) => {
state.prefsStorage = state.prefsStorage || {
simple: {},
collections: {}
}
Object.entries(defaultState.prefsStorage.simple).forEach(([k, v]) => {
if (typeof v === 'number' || typeof v === 'boolean') return
console.warn(`Preference simple.${k} as invalid type, reinitializing`)
set(state.prefsStorage.simple, k, defaultState.prefsStorage.simple[k])
})
Object.entries(defaultState.prefsStorage.collections).forEach(([k, v]) => {
if (Array.isArray(v)) return
console.warn(`Preference collections.${k} as invalid type, reinitializing`)
set(state.prefsStorage.collections, k, defaultState.prefsStorage.collections[k])
})
}
export const _getRecentData = (cache, live) => {
const result = { recent: null, stale: null, needUpload: false }
const cacheValid = _checkValidity(cache || {})
@ -85,6 +121,8 @@ export const _getAllFlags = (recent, stale) => {
}
export const _mergeFlags = (recent, stale, allFlagKeys) => {
if (!stale.flagStorage) return recent.flagStorage
if (!recent.flagStorage) return stale.flagStorage
return Object.fromEntries(allFlagKeys.map(flag => {
const recentFlag = recent.flagStorage[flag]
const staleFlag = stale.flagStorage[flag]
@ -93,6 +131,88 @@ export const _mergeFlags = (recent, stale, allFlagKeys) => {
}))
}
const _mergeJournal = (...journals) => {
// Ignore invalid journal entries
const allJournals = flatten(
journals.map(j => Array.isArray(j) ? j : [])
).filter(entry =>
Object.prototype.hasOwnProperty.call(entry, 'path') &&
Object.prototype.hasOwnProperty.call(entry, 'operation') &&
Object.prototype.hasOwnProperty.call(entry, 'args') &&
Object.prototype.hasOwnProperty.call(entry, 'timestamp')
)
const grouped = groupBy(allJournals, 'path')
const trimmedGrouped = Object.entries(grouped).map(([path, journal]) => {
// side effect
journal.sort((a, b) => a.timestamp > b.timestamp ? 1 : -1)
if (path.startsWith('collections')) {
const lastRemoveIndex = findLastIndex(journal, ({ operation }) => operation === 'removeFromCollection')
// everything before last remove is unimportant
if (lastRemoveIndex > 0) {
return journal.slice(lastRemoveIndex)
} else {
// everything else doesn't need trimming
return journal
}
} else if (path.startsWith('simple')) {
// Only the last record is important
return takeRight(journal)
} else {
return journal
}
})
return flatten(trimmedGrouped)
.sort((a, b) => a.timestamp > b.timestamp ? 1 : -1)
}
export const _mergePrefs = (recent, stale, allFlagKeys) => {
if (!stale) return recent
if (!recent) return stale
const { _journal: recentJournal, ...recentData } = recent
const { _journal: staleJournal } = stale
/** Journal entry format:
* path: path to entry in prefsStorage
* timestamp: timestamp of the change
* operation: operation type
* arguments: array of arguments, depends on operation type
*
* currently only supported operation type is "set" which just sets the value
* to requested one. Intended only to be used with simple preferences (boolean, number)
* shouldn't be used with collections!
*/
const resultOutput = { ...recentData }
const totalJournal = _mergeJournal(staleJournal, recentJournal)
totalJournal.forEach(({ path, timestamp, operation, command, args }) => {
if (path.startsWith('_')) {
console.error(`journal contains entry to edit internal (starts with _) field '${path}', something is incorrect here, ignoring.`)
return
}
switch (operation) {
case 'set':
set(resultOutput, path, args[0])
break
case 'addToCollection':
set(resultOutput, path, Array.from(new Set(get(resultOutput, path)).add(args[0])))
break
case 'removeFromCollection': {
const newSet = new Set(get(resultOutput, path))
newSet.delete(args[0])
set(resultOutput, path, Array.from(newSet))
break
}
case 'reorderCollection': {
const [value, movement] = args
set(resultOutput, path, _moveItemInArray(get(resultOutput, path), value, movement))
break
}
default:
console.error(`Unknown journal operation: '${operation}', did we forget to run reverse migrations beforehand?`)
}
})
return { ...resultOutput, _journal: totalJournal }
}
export const _resetFlags = (totalFlags, knownKeys = defaultState.flagStorage) => {
let result = { ...totalFlags }
const allFlagKeys = Object.keys(totalFlags)
@ -149,10 +269,17 @@ export const _doMigrations = (cache) => {
}
export const mutations = {
clearServerSideStorage (state, userData) {
state = { ...cloneDeep(defaultState) }
},
setServerSideStorage (state, userData) {
const live = userData.storage
state.raw = live
let cache = state.cache
if (cache && cache._user !== userData.fqn) {
console.warn('cache belongs to another user! reinitializing local cache!')
cache = null
}
cache = _doMigrations(cache)
@ -165,7 +292,8 @@ export const mutations = {
if (recent === null) {
console.debug(`Data is empty, initializing for ${userNew ? 'new' : 'existing'} user`)
recent = _wrapData({
flagStorage: { ...flagsTemplate }
flagStorage: { ...flagsTemplate },
prefsStorage: { ...defaultState.prefsStorage }
})
}
@ -180,17 +308,23 @@ export const mutations = {
const allFlagKeys = _getAllFlags(recent, stale)
let totalFlags
let totalPrefs
if (dirty) {
// Merge the flags
console.debug('Merging the flags...')
console.debug('Merging the data...')
totalFlags = _mergeFlags(recent, stale, allFlagKeys)
_verifyPrefs(recent)
_verifyPrefs(stale)
totalPrefs = _mergePrefs(recent.prefsStorage, stale.prefsStorage)
} else {
totalFlags = recent.flagStorage
totalPrefs = recent.prefsStorage
}
totalFlags = _resetFlags(totalFlags)
recent.flagStorage = totalFlags
recent.flagStorage = { ...flagsTemplate, ...totalFlags }
recent.prefsStorage = { ...defaultState.prefsStorage, ...totalPrefs }
state.dirty = dirty || needsUpload
state.cache = recent
@ -199,10 +333,72 @@ export const mutations = {
state.cache._timestamp = Math.min(stale._timestamp, recent._timestamp)
}
state.flagStorage = state.cache.flagStorage
state.prefsStorage = state.cache.prefsStorage
},
setFlag (state, { flag, value }) {
state.flagStorage[flag] = value
state.dirty = true
},
setPreference (state, { path, value }) {
if (path.startsWith('_')) {
console.error(`tried to edit internal (starts with _) field '${path}', ignoring.`)
return
}
set(state.prefsStorage, path, value)
state.prefsStorage._journal = [
...state.prefsStorage._journal,
{ operation: 'set', path, args: [value], timestamp: Date.now() }
]
state.dirty = true
},
addCollectionPreference (state, { path, value }) {
if (path.startsWith('_')) {
console.error(`tried to edit internal (starts with _) field '${path}', ignoring.`)
return
}
const collection = new Set(get(state.prefsStorage, path))
collection.add(value)
set(state.prefsStorage, path, [...collection])
state.prefsStorage._journal = [
...state.prefsStorage._journal,
{ operation: 'addToCollection', path, args: [value], timestamp: Date.now() }
]
state.dirty = true
},
removeCollectionPreference (state, { path, value }) {
if (path.startsWith('_')) {
console.error(`tried to edit internal (starts with _) field '${path}', ignoring.`)
return
}
const collection = new Set(get(state.prefsStorage, path))
collection.delete(value)
set(state.prefsStorage, path, [...collection])
state.prefsStorage._journal = [
...state.prefsStorage._journal,
{ operation: 'removeFromCollection', path, args: [value], timestamp: Date.now() }
]
state.dirty = true
},
reorderCollectionPreference (state, { path, value, movement }) {
if (path.startsWith('_')) {
console.error(`tried to edit internal (starts with _) field '${path}', ignoring.`)
return
}
const collection = get(state.prefsStorage, path)
const newCollection = _moveItemInArray(collection, value, movement)
set(state.prefsStorage, path, newCollection)
state.prefsStorage._journal = [
...state.prefsStorage._journal,
{ operation: 'arrangeCollection', path, args: [value], timestamp: Date.now() }
]
state.dirty = true
},
updateCache (state, { username }) {
state.prefsStorage._journal = _mergeJournal(state.prefsStorage._journal)
state.cache = _wrapData({
flagStorage: toRaw(state.flagStorage),
prefsStorage: toRaw(state.prefsStorage)
}, username)
}
}
@ -214,15 +410,16 @@ const serverSideStorage = {
actions: {
pushServerSideStorage ({ state, rootState, commit }, { force = false } = {}) {
const needPush = state.dirty || force
console.log(needPush)
if (!needPush) return
state.cache = _wrapData({
flagStorage: toRaw(state.flagStorage)
})
commit('updateCache', { username: rootState.users.currentUser.fqn })
const params = { pleroma_settings_store: { 'pleroma-fe': state.cache } }
rootState.api.backendInteractor
.updateProfile({ params })
.then((user) => commit('setServerSideStorage', user))
state.dirty = false
.then((user) => {
commit('setServerSideStorage', user)
state.dirty = false
})
}
}
}

View file

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

View file

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

View file

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

View file

@ -43,11 +43,13 @@ export const parseUser = (data) => {
// case for users in "mentions" property for statuses in MastoAPI
const mastoShort = masto && !Object.prototype.hasOwnProperty.call(data, 'avatar')
output.inLists = null
output.id = String(data.id)
output._original = data // used for server-side settings
if (masto) {
output.screen_name = data.acct
output.fqn = data.fqn
output.statusnet_profile_url = data.url
// There's nothing else to get
@ -214,12 +216,14 @@ export const parseUser = (data) => {
output.screen_name_ui = output.screen_name
if (output.screen_name && output.screen_name.includes('@')) {
const parts = output.screen_name.split('@')
let unicodeDomain = punycode.toUnicode(parts[1])
const unicodeDomain = punycode.toUnicode(parts[1])
if (unicodeDomain !== parts[1]) {
// Add some identifier so users can potentially spot spoofing attempts:
// 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('@')
} else {
output.screen_name_ui_contains_non_ascii = false
}
}

View file

@ -17,11 +17,6 @@ const webpackConfig = merge(baseConfig, {
rules: utils.styleLoaders()
},
devtool: 'inline-source-map',
// vue: {
// loaders: {
// js: 'isparta'
// }
// },
plugins: [
new webpack.DefinePlugin({
'process.env': require('../../config/test.env')
@ -37,22 +32,6 @@ const webpackConfig = merge(baseConfig, {
// no need for app entry during tests
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) {
config.set({
// to run in additional browsers:

View file

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

View file

@ -4,9 +4,11 @@ import {
VERSION,
COMMAND_TRIM_FLAGS,
COMMAND_TRIM_FLAGS_AND_RESET,
_moveItemInArray,
_getRecentData,
_getAllFlags,
_mergeFlags,
_mergePrefs,
_resetFlags,
mutations,
defaultState,
@ -28,6 +30,7 @@ describe('The serverSideStorage module', () => {
expect(state.cache._version).to.eql(VERSION)
expect(state.cache._timestamp).to.be.a('number')
expect(state.cache.flagStorage).to.eql(defaultState.flagStorage)
expect(state.cache.prefsStorage).to.eql(defaultState.prefsStorage)
})
it('should initialize storage with proper flags for new users if none present', () => {
@ -36,6 +39,7 @@ describe('The serverSideStorage module', () => {
expect(state.cache._version).to.eql(VERSION)
expect(state.cache._timestamp).to.be.a('number')
expect(state.cache.flagStorage).to.eql(newUserFlags)
expect(state.cache.prefsStorage).to.eql(defaultState.prefsStorage)
})
it('should merge flags even if remote timestamp is older', () => {
@ -57,6 +61,9 @@ describe('The serverSideStorage module', () => {
flagStorage: {
...defaultState.flagStorage,
updateCounter: 1
},
prefsStorage: {
...defaultState.prefsStorage
}
}
}
@ -99,9 +106,62 @@ describe('The serverSideStorage module', () => {
expect(state.cache.flagStorage).to.eql(defaultState.flagStorage)
})
})
describe('setPreference', () => {
const { setPreference, updateCache, addCollectionPreference, removeCollectionPreference } = mutations
it('should set preference and update journal log accordingly', () => {
const state = cloneDeep(defaultState)
setPreference(state, { path: 'simple.testing', value: 1 })
expect(state.prefsStorage.simple.testing).to.eql(1)
expect(state.prefsStorage._journal.length).to.eql(1)
expect(state.prefsStorage._journal[0]).to.eql({
path: 'simple.testing',
operation: 'set',
args: [1],
// should have A timestamp, we don't really care what it is
timestamp: state.prefsStorage._journal[0].timestamp
})
})
it('should keep journal to a minimum', () => {
const state = cloneDeep(defaultState)
setPreference(state, { path: 'simple.testing', value: 1 })
setPreference(state, { path: 'simple.testing', value: 2 })
addCollectionPreference(state, { path: 'collections.testing', value: 2 })
removeCollectionPreference(state, { path: 'collections.testing', value: 2 })
updateCache(state, { username: 'test' })
expect(state.prefsStorage.simple.testing).to.eql(2)
expect(state.prefsStorage.collections.testing).to.eql([])
expect(state.prefsStorage._journal.length).to.eql(2)
expect(state.prefsStorage._journal[0]).to.eql({
path: 'simple.testing',
operation: 'set',
args: [2],
// should have A timestamp, we don't really care what it is
timestamp: state.prefsStorage._journal[0].timestamp
})
expect(state.prefsStorage._journal[1]).to.eql({
path: 'collections.testing',
operation: 'removeFromCollection',
args: [2],
// should have A timestamp, we don't really care what it is
timestamp: state.prefsStorage._journal[1].timestamp
})
})
})
})
describe('helper functions', () => {
describe('_moveItemInArray', () => {
it('should move item according to movement value', () => {
expect(_moveItemInArray([1, 2, 3, 4], 4, -1)).to.eql([1, 2, 4, 3])
expect(_moveItemInArray([1, 2, 3, 4], 1, 2)).to.eql([2, 3, 1, 4])
})
it('should clamp movement to within array', () => {
expect(_moveItemInArray([1, 2, 3, 4], 4, -10)).to.eql([4, 1, 2, 3])
expect(_moveItemInArray([1, 2, 3, 4], 3, 99)).to.eql([1, 2, 4, 3])
})
})
describe('_getRecentData', () => {
it('should handle nulls correctly', () => {
expect(_getRecentData(null, null)).to.eql({ recent: null, stale: null, needUpload: true })
@ -157,6 +217,94 @@ describe('The serverSideStorage module', () => {
})
})
describe('_mergePrefs', () => {
it('should prefer recent and apply journal to it', () => {
expect(
_mergePrefs(
// RECENT
{
simple: { a: 1, b: 0, c: true },
_journal: [
{ path: 'simple.b', operation: 'set', args: [0], timestamp: 2 },
{ path: 'simple.c', operation: 'set', args: [true], timestamp: 4 }
]
},
// STALE
{
simple: { a: 1, b: 1, c: false },
_journal: [
{ path: 'simple.a', operation: 'set', args: [1], timestamp: 1 },
{ path: 'simple.b', operation: 'set', args: [1], timestamp: 3 }
]
}
)
).to.eql({
simple: { a: 1, b: 1, c: true },
_journal: [
{ path: 'simple.a', operation: 'set', args: [1], timestamp: 1 },
{ path: 'simple.b', operation: 'set', args: [1], timestamp: 3 },
{ path: 'simple.c', operation: 'set', args: [true], timestamp: 4 }
]
})
})
it('should allow setting falsy values', () => {
expect(
_mergePrefs(
// RECENT
{
simple: { a: 1, b: 0, c: false },
_journal: [
{ path: 'simple.b', operation: 'set', args: [0], timestamp: 2 },
{ path: 'simple.c', operation: 'set', args: [false], timestamp: 4 }
]
},
// STALE
{
simple: { a: 0, b: 0, c: true },
_journal: [
{ path: 'simple.a', operation: 'set', args: [0], timestamp: 1 },
{ path: 'simple.b', operation: 'set', args: [0], timestamp: 3 }
]
}
)
).to.eql({
simple: { a: 0, b: 0, c: false },
_journal: [
{ path: 'simple.a', operation: 'set', args: [0], timestamp: 1 },
{ path: 'simple.b', operation: 'set', args: [0], timestamp: 3 },
{ path: 'simple.c', operation: 'set', args: [false], timestamp: 4 }
]
})
})
it('should work with strings', () => {
expect(
_mergePrefs(
// RECENT
{
simple: { a: 'foo' },
_journal: [
{ path: 'simple.a', operation: 'set', args: ['foo'], timestamp: 2 }
]
},
// STALE
{
simple: { a: 'bar' },
_journal: [
{ path: 'simple.a', operation: 'set', args: ['bar'], timestamp: 4 }
]
}
)
).to.eql({
simple: { a: 'bar' },
_journal: [
{ path: 'simple.a', operation: 'set', args: ['bar'], timestamp: 4 }
]
})
})
})
describe('_resetFlags', () => {
it('should reset all known flags to 0 when reset flag is set to > 0 and < 9000', () => {
const totalFlags = { a: 0, b: 3, reset: 1 }

View file

@ -269,7 +269,8 @@ describe('API Entities normalizer', () => {
it('converts IDN to unicode and marks it as internatonal', () => {
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