From 171f6f08943dd1d87120df3e4894ddcfd5e1d246 Mon Sep 17 00:00:00 2001 From: Alexander Tumin Date: Sat, 6 Aug 2022 17:26:43 +0300 Subject: [PATCH] Lists implementation --- src/boot/routes.js | 8 +- src/components/lists/lists.js | 32 ++++++ src/components/lists/lists.vue | 31 +++++ src/components/lists_card/lists_card.js | 16 +++ src/components/lists_card/lists_card.vue | 51 +++++++++ src/components/lists_edit/lists_edit.js | 91 +++++++++++++++ src/components/lists_edit/lists_edit.vue | 108 ++++++++++++++++++ .../lists_menu/lists_menu_content.js | 33 ++++++ .../lists_menu/lists_menu_content.vue | 17 +++ src/components/lists_new/lists_new.js | 79 +++++++++++++ src/components/lists_new/lists_new.vue | 95 +++++++++++++++ .../lists_timeline/lists_timeline.js | 36 ++++++ .../lists_timeline/lists_timeline.vue | 10 ++ .../lists_user_search/lists_user_search.js | 46 ++++++++ .../lists_user_search/lists_user_search.vue | 45 ++++++++ src/components/nav_panel/nav_panel.js | 19 ++- src/components/nav_panel/nav_panel.vue | 45 ++++++++ .../settings_modal/tabs/general_tab.vue | 47 ++++++++ src/components/side_drawer/side_drawer.js | 6 +- src/components/side_drawer/side_drawer.vue | 12 ++ src/components/timeline/timeline.js | 3 + src/components/timeline_menu/timeline_menu.js | 3 + src/i18n/en.json | 14 ++- src/main.js | 2 + src/modules/api.js | 17 ++- src/modules/config.js | 4 + src/modules/lists.js | 94 +++++++++++++++ src/modules/statuses.js | 3 +- src/services/api/api.service.js | 93 +++++++++++++++ .../backend_interactor_service.js | 9 +- .../lists_fetcher/lists_fetcher.service.js | 22 ++++ .../timeline_fetcher.service.js | 14 ++- test/unit/specs/boot/routes.spec.js | 24 ++++ test/unit/specs/modules/lists.spec.js | 83 ++++++++++++++ 34 files changed, 1194 insertions(+), 18 deletions(-) create mode 100644 src/components/lists/lists.js create mode 100644 src/components/lists/lists.vue create mode 100644 src/components/lists_card/lists_card.js create mode 100644 src/components/lists_card/lists_card.vue create mode 100644 src/components/lists_edit/lists_edit.js create mode 100644 src/components/lists_edit/lists_edit.vue create mode 100644 src/components/lists_menu/lists_menu_content.js create mode 100644 src/components/lists_menu/lists_menu_content.vue create mode 100644 src/components/lists_new/lists_new.js create mode 100644 src/components/lists_new/lists_new.vue create mode 100644 src/components/lists_timeline/lists_timeline.js create mode 100644 src/components/lists_timeline/lists_timeline.vue create mode 100644 src/components/lists_user_search/lists_user_search.js create mode 100644 src/components/lists_user_search/lists_user_search.vue create mode 100644 src/modules/lists.js create mode 100644 src/services/lists_fetcher/lists_fetcher.service.js create mode 100644 test/unit/specs/modules/lists.spec.js diff --git a/src/boot/routes.js b/src/boot/routes.js index c8194d5f..8bb7d196 100644 --- a/src/boot/routes.js +++ b/src/boot/routes.js @@ -20,6 +20,9 @@ import ShoutPanel from 'components/shout_panel/shout_panel.vue' import WhoToFollow from 'components/who_to_follow/who_to_follow.vue' import About from 'components/about/about.vue' import RemoteUserResolver from 'components/remote_user_resolver/remote_user_resolver.vue' +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' export default (store) => { const validateAuthenticatedRoute = (to, from, next) => { @@ -72,7 +75,10 @@ export default (store) => { { name: 'search', path: '/search', component: Search, props: (route) => ({ query: route.query.query }) }, { name: 'who-to-follow', path: '/who-to-follow', component: WhoToFollow, beforeEnter: validateAuthenticatedRoute }, { name: 'about', path: '/about', component: About }, - { name: 'user-profile', path: '/:_(users)?/:name', component: UserProfile } + { name: 'user-profile', path: '/:_(users)?/:name', component: UserProfile }, + { name: 'lists', path: '/lists', component: Lists }, + { name: 'list-timeline', path: '/lists/:id', component: ListsTimeline }, + { name: 'list-edit', path: '/lists/:id/edit', component: ListsEdit } ] if (store.state.instance.pleromaChatMessagesAvailable) { diff --git a/src/components/lists/lists.js b/src/components/lists/lists.js new file mode 100644 index 00000000..791b99b2 --- /dev/null +++ b/src/components/lists/lists.js @@ -0,0 +1,32 @@ +import ListsCard from '../lists_card/lists_card.vue' +import ListsNew from '../lists_new/lists_new.vue' + +const Lists = { + data () { + return { + isNew: false + } + }, + components: { + ListsCard, + ListsNew + }, + created () { + this.$store.dispatch('startFetchingLists') + }, + computed: { + lists () { + return this.$store.state.lists.allLists + } + }, + methods: { + cancelNewList () { + this.isNew = false + }, + newList () { + this.isNew = true + } + } +} + +export default Lists diff --git a/src/components/lists/lists.vue b/src/components/lists/lists.vue new file mode 100644 index 00000000..fcc56447 --- /dev/null +++ b/src/components/lists/lists.vue @@ -0,0 +1,31 @@ + + + diff --git a/src/components/lists_card/lists_card.js b/src/components/lists_card/lists_card.js new file mode 100644 index 00000000..b503caec --- /dev/null +++ b/src/components/lists_card/lists_card.js @@ -0,0 +1,16 @@ +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faEllipsisH +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faEllipsisH +) + +const ListsCard = { + props: [ + 'list' + ] +} + +export default ListsCard diff --git a/src/components/lists_card/lists_card.vue b/src/components/lists_card/lists_card.vue new file mode 100644 index 00000000..c0d58f18 --- /dev/null +++ b/src/components/lists_card/lists_card.vue @@ -0,0 +1,51 @@ + + + + + diff --git a/src/components/lists_edit/lists_edit.js b/src/components/lists_edit/lists_edit.js new file mode 100644 index 00000000..7b642929 --- /dev/null +++ b/src/components/lists_edit/lists_edit.js @@ -0,0 +1,91 @@ +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 UserAvatar from '../user_avatar/user_avatar.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: [] + } + }, + created () { + this.$store.dispatch('fetchList', { id: this.id }) + .then(() => { this.title = this.findListTitle(this.id) }) + this.$store.dispatch('fetchListAccounts', { id: this.id }) + .then(() => { + this.selectedUserIds = this.findListAccounts(this.id) + this.selectedUserIds.forEach(userId => { + this.$store.dispatch('fetchUserIfMissing', userId) + }) + }) + }, + computed: { + id () { + return this.$route.params.id + }, + users () { + return this.userIds.map(userId => this.findUser(userId)) + }, + selectedUsers () { + return this.selectedUserIds.map(userId => this.findUser(userId)).filter(user => user) + }, + ...mapState({ + currentUser: state => state.users.currentUser + }), + ...mapGetters(['findUser', 'findListTitle', 'findListAccounts']) + }, + methods: { + 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 + }, + 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: 'list-timeline', params: { id: this.id } }) + }, + deleteList () { + this.$store.dispatch('deleteList', { id: this.id }) + this.$router.push({ name: 'lists' }) + } + } +} + +export default ListsNew diff --git a/src/components/lists_edit/lists_edit.vue b/src/components/lists_edit/lists_edit.vue new file mode 100644 index 00000000..69007b02 --- /dev/null +++ b/src/components/lists_edit/lists_edit.vue @@ -0,0 +1,108 @@ + + + + + diff --git a/src/components/lists_menu/lists_menu_content.js b/src/components/lists_menu/lists_menu_content.js new file mode 100644 index 00000000..37e7868c --- /dev/null +++ b/src/components/lists_menu/lists_menu_content.js @@ -0,0 +1,33 @@ +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 ListsMenuContent = { + created () { + this.$store.dispatch('startFetchingLists') + }, + computed: { + ...mapState({ + lists: state => state.lists.allLists, + currentUser: state => state.users.currentUser, + privateMode: state => state.instance.private, + federating: state => state.instance.federating + }) + } +} + +export default ListsMenuContent diff --git a/src/components/lists_menu/lists_menu_content.vue b/src/components/lists_menu/lists_menu_content.vue new file mode 100644 index 00000000..5606b74f --- /dev/null +++ b/src/components/lists_menu/lists_menu_content.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/components/lists_new/lists_new.js b/src/components/lists_new/lists_new.js new file mode 100644 index 00000000..7e3470fa --- /dev/null +++ b/src/components/lists_new/lists_new.js @@ -0,0 +1,79 @@ +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: 'list-timeline', params: { id: list.id } }) + }) + } + } +} + +export default ListsNew diff --git a/src/components/lists_new/lists_new.vue b/src/components/lists_new/lists_new.vue new file mode 100644 index 00000000..4733bdde --- /dev/null +++ b/src/components/lists_new/lists_new.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/src/components/lists_timeline/lists_timeline.js b/src/components/lists_timeline/lists_timeline.js new file mode 100644 index 00000000..4611a736 --- /dev/null +++ b/src/components/lists_timeline/lists_timeline.js @@ -0,0 +1,36 @@ +import Timeline from '../timeline/timeline.vue' +const ListsTimeline = { + data () { + return { + listId: null + } + }, + components: { + Timeline + }, + computed: { + timeline () { return this.$store.state.statuses.timelines.list } + }, + watch: { + $route: function (route) { + if (route.name === 'list-timeline' && route.params.id !== this.listId) { + 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('startFetchingTimeline', { timeline: 'list', listId: this.listId }) + } + } + }, + created () { + this.listId = this.$route.params.id + this.$store.dispatch('fetchList', { id: this.listId }) + this.$store.dispatch('startFetchingTimeline', { timeline: 'list', listId: this.listId }) + }, + unmounted () { + this.$store.dispatch('stopFetchingTimeline', 'list') + this.$store.commit('clearTimeline', { timeline: 'list' }) + } +} + +export default ListsTimeline diff --git a/src/components/lists_timeline/lists_timeline.vue b/src/components/lists_timeline/lists_timeline.vue new file mode 100644 index 00000000..18156b81 --- /dev/null +++ b/src/components/lists_timeline/lists_timeline.vue @@ -0,0 +1,10 @@ + + + diff --git a/src/components/lists_user_search/lists_user_search.js b/src/components/lists_user_search/lists_user_search.js new file mode 100644 index 00000000..5798841a --- /dev/null +++ b/src/components/lists_user_search/lists_user_search.js @@ -0,0 +1,46 @@ +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faSearch, + faChevronLeft +} from '@fortawesome/free-solid-svg-icons' +import { debounce } from 'lodash' +import Checkbox from '../checkbox/checkbox.vue' + +library.add( + faSearch, + faChevronLeft +) + +const ListsUserSearch = { + components: { + Checkbox + }, + data () { + return { + loading: false, + query: '', + followingOnly: true + } + }, + methods: { + onInput: debounce(function () { + this.search(this.query) + }, 2000), + search (query) { + if (!query) { + this.loading = false + return + } + + this.loading = true + 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)) + }) + } + } +} + +export default ListsUserSearch diff --git a/src/components/lists_user_search/lists_user_search.vue b/src/components/lists_user_search/lists_user_search.vue new file mode 100644 index 00000000..03fb3ba6 --- /dev/null +++ b/src/components/lists_user_search/lists_user_search.vue @@ -0,0 +1,45 @@ + + + + diff --git a/src/components/nav_panel/nav_panel.js b/src/components/nav_panel/nav_panel.js index 37bcb409..abeff6bf 100644 --- a/src/components/nav_panel/nav_panel.js +++ b/src/components/nav_panel/nav_panel.js @@ -1,4 +1,5 @@ import TimelineMenuContent from '../timeline_menu/timeline_menu_content.vue' +import ListsMenuContent from '../lists_menu/lists_menu_content.vue' import { mapState, mapGetters } from 'vuex' import { library } from '@fortawesome/fontawesome-svg-core' @@ -12,7 +13,8 @@ import { faComments, faBell, faInfoCircle, - faStream + faStream, + faList } from '@fortawesome/free-solid-svg-icons' library.add( @@ -25,7 +27,8 @@ library.add( faComments, faBell, faInfoCircle, - faStream + faStream, + faList ) const NavPanel = { @@ -35,19 +38,27 @@ const NavPanel = { } }, components: { - TimelineMenuContent + TimelineMenuContent, + ListsMenuContent }, data () { return { - showTimelines: false + showTimelines: false, + showLists: false } }, methods: { toggleTimelines () { this.showTimelines = !this.showTimelines + }, + toggleLists () { + this.showLists = !this.showLists } }, computed: { + listsNavigation () { + return this.$store.getters.mergedConfig.listsNavigation + }, ...mapState({ currentUser: state => state.users.currentUser, followRequestCount: state => state.api.followRequests.length, diff --git a/src/components/nav_panel/nav_panel.vue b/src/components/nav_panel/nav_panel.vue index 3fd27d89..9322e233 100644 --- a/src/components/nav_panel/nav_panel.vue +++ b/src/components/nav_panel/nav_panel.vue @@ -25,6 +25,51 @@ +
  • + +
    + +
    +
  • +
  • + + + +
  • +
  • + + {{ $t('settings.lists_navigation') }} + +
  • +
  • +

    {{ $t('settings.columns') }}

    +
  • +
  • + + {{ $t('settings.disable_sticky_headers') }} + +
  • +
  • + + {{ $t('settings.show_scrollbars') }} + +
  • +
  • + + {{ $t('settings.right_sidebar') }} + +
  • +
  • + + {{ $t('settings.third_column_mode') }} + +
  • +
  • + {{ $t('settings.column_sizes') }} +
    + + {{ $t('settings.column_sizes_' + column) }} + +
    +
  • diff --git a/src/components/side_drawer/side_drawer.js b/src/components/side_drawer/side_drawer.js index f45f8def..913fa695 100644 --- a/src/components/side_drawer/side_drawer.js +++ b/src/components/side_drawer/side_drawer.js @@ -14,7 +14,8 @@ import { faSearch, faTachometerAlt, faCog, - faInfoCircle + faInfoCircle, + faList } from '@fortawesome/free-solid-svg-icons' library.add( @@ -28,7 +29,8 @@ library.add( faSearch, faTachometerAlt, faCog, - faInfoCircle + faInfoCircle, + faList ) const SideDrawer = { diff --git a/src/components/side_drawer/side_drawer.vue b/src/components/side_drawer/side_drawer.vue index 7547fb08..7d9d36d7 100644 --- a/src/components/side_drawer/side_drawer.vue +++ b/src/components/side_drawer/side_drawer.vue @@ -55,6 +55,18 @@ /> {{ $t("nav.timelines") }} +
  • + + {{ $t("nav.lists") }} + +
  • { if (statuses && statuses.length === 0) { diff --git a/src/components/timeline_menu/timeline_menu.js b/src/components/timeline_menu/timeline_menu.js index a11e7b7e..0792dc13 100644 --- a/src/components/timeline_menu/timeline_menu.js +++ b/src/components/timeline_menu/timeline_menu.js @@ -58,6 +58,9 @@ const TimelineMenu = { if (route === 'tag-timeline') { return '#' + this.$route.params.tag } + if (route === 'list-timeline') { + return this.$store.getters.findListTitle(this.$route.params.id) + } const i18nkey = timelineNames()[this.$route.name] return i18nkey ? this.$t(i18nkey) : route } diff --git a/src/i18n/en.json b/src/i18n/en.json index a10b271a..2f9c372e 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -146,7 +146,8 @@ "who_to_follow": "Who to follow", "preferences": "Preferences", "timelines": "Timelines", - "chats": "Chats" + "chats": "Chats", + "lists": "Lists" }, "notifications": { "broken_favorite": "Unknown status, searching for it…", @@ -298,6 +299,7 @@ "desc": "To enable two-factor authentication, enter the code from your two-factor app:" } }, + "lists_navigation": "Show lists in navigation", "allow_following_move": "Allow auto-follow when following account moves", "attachmentRadius": "Attachments", "attachments": "Attachments", @@ -948,6 +950,16 @@ "error_sending_message": "Something went wrong when sending the message.", "empty_chat_list_placeholder": "You don't have any chats yet. Start a new chat!" }, + "lists": { + "lists": "Lists", + "new": "New List", + "title": "List title", + "search": "Search users", + "create": "Create", + "save": "Save changes", + "delete": "Delete list", + "following_only": "Limit to Following" + }, "file_type": { "audio": "Audio", "video": "Video", diff --git a/src/main.js b/src/main.js index eacd554c..7d2c82cb 100644 --- a/src/main.js +++ b/src/main.js @@ -6,6 +6,7 @@ import './lib/event_target_polyfill.js' import interfaceModule from './modules/interface.js' import instanceModule from './modules/instance.js' import statusesModule from './modules/statuses.js' +import listsModule from './modules/lists.js' import usersModule from './modules/users.js' import apiModule from './modules/api.js' import configModule from './modules/config.js' @@ -70,6 +71,7 @@ const persistedStateOptions = { // TODO refactor users/statuses modules, they depend on each other users: usersModule, statuses: statusesModule, + lists: listsModule, api: apiModule, config: configModule, serverSideConfig: serverSideConfigModule, diff --git a/src/modules/api.js b/src/modules/api.js index 28f2076e..80a978f9 100644 --- a/src/modules/api.js +++ b/src/modules/api.js @@ -191,12 +191,13 @@ const api = { startFetchingTimeline (store, { timeline = 'friends', tag = false, - userId = false + userId = false, + listId = false }) { if (store.state.fetchers[timeline]) return const fetcher = store.state.backendInteractor.startFetchingTimeline({ - timeline, store, userId, tag + timeline, store, userId, listId, tag }) store.commit('addFetcher', { fetcherName: timeline, fetcher }) }, @@ -248,6 +249,18 @@ const api = { store.commit('setFollowRequests', requests) }, + // Lists + startFetchingLists (store) { + if (store.state.fetchers.lists) return + const fetcher = store.state.backendInteractor.startFetchingLists({ store }) + store.commit('addFetcher', { fetcherName: 'lists', fetcher }) + }, + stopFetchingLists (store) { + const fetcher = store.state.fetchers.lists + if (!fetcher) return + store.commit('removeFetcher', { fetcherName: 'lists', fetcher }) + }, + // Pleroma websocket setWsToken (store, token) { store.commit('setWsToken', token) diff --git a/src/modules/config.js b/src/modules/config.js index eaf67a91..910fb172 100644 --- a/src/modules/config.js +++ b/src/modules/config.js @@ -83,6 +83,10 @@ export const defaultState = { showScrollbars: false, userPopoverZoom: false, userPopoverOverlay: true, + sidebarColumnWidth: '25rem', + contentColumnWidth: '45rem', + notifsColumnWidth: '25rem', + listsNavigation: false, greentext: undefined, // instance default useAtIcon: undefined, // instance default mentionLinkDisplay: undefined, // instance default diff --git a/src/modules/lists.js b/src/modules/lists.js new file mode 100644 index 00000000..58700f41 --- /dev/null +++ b/src/modules/lists.js @@ -0,0 +1,94 @@ +import { remove, find } from 'lodash' + +export const defaultState = { + allLists: [], + allListsObject: {} +} + +export const mutations = { + setLists (state, value) { + state.allLists = value + }, + setList (state, { id, title }) { + if (!state.allListsObject[id]) { + state.allListsObject[id] = {} + } + state.allListsObject[id].title = title + + if (!find(state.allLists, { id })) { + state.allLists.push({ id, title }) + } else { + find(state.allLists, { id }).title = title + } + }, + setListAccounts (state, { id, accountIds }) { + if (!state.allListsObject[id]) { + state.allListsObject[id] = {} + } + state.allListsObject[id].accountIds = accountIds + }, + deleteList (state, { id }) { + delete state.allListsObject[id] + remove(state.allLists, list => list.id === id) + } +} + +const actions = { + setLists ({ commit }, value) { + commit('setLists', value) + }, + createList ({ rootState, commit }, { title }) { + return rootState.api.backendInteractor.createList({ title }) + .then((list) => { + commit('setList', { id: 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 })) + }, + fetchListAccounts ({ rootState, commit }, { id }) { + return rootState.api.backendInteractor.getListAccounts({ id }) + .then((accountIds) => commit('setListAccounts', { id, accountIds })) + }, + setList ({ rootState, commit }, { id, title }) { + rootState.api.backendInteractor.updateList({ id, title }) + commit('setList', { id, title }) + }, + setListAccounts ({ rootState, commit }, { id, accountIds }) { + const saved = rootState.lists.allListsObject[id].accountIds + const added = accountIds.filter(id => !saved.includes(id)) + const removed = saved.filter(id => !accountIds.includes(id)) + commit('setListAccounts', { id, accountIds }) + if (added.length > 0) { + rootState.api.backendInteractor.addAccountsToList({ id, accountIds: added }) + } + if (removed.length > 0) { + rootState.api.backendInteractor.removeAccountsFromList({ id, accountIds: removed }) + } + }, + deleteList ({ rootState, commit }, { id }) { + rootState.api.backendInteractor.deleteList({ id }) + commit('deleteList', { id }) + } +} + +export const getters = { + findListTitle: state => id => { + if (!state.allListsObject[id]) return + return state.allListsObject[id].title + }, + findListAccounts: state => id => { + return [...state.allListsObject[id].accountIds] + } +} + +const lists = { + state: defaultState, + mutations, + actions, + getters +} + +export default lists diff --git a/src/modules/statuses.js b/src/modules/statuses.js index 62251b0b..c4475005 100644 --- a/src/modules/statuses.js +++ b/src/modules/statuses.js @@ -62,7 +62,8 @@ export const defaultState = () => ({ friends: emptyTl(), tag: emptyTl(), dms: emptyTl(), - bookmarks: emptyTl() + bookmarks: emptyTl(), + list: emptyTl() } }) diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js index 7b7fbefd..86d9eaf4 100644 --- a/src/services/api/api.service.js +++ b/src/services/api/api.service.js @@ -52,6 +52,9 @@ const MASTODON_STATUS_CONTEXT_URL = id => `/api/v1/statuses/${id}/context` const MASTODON_USER_URL = '/api/v1/accounts' const MASTODON_USER_RELATIONSHIPS_URL = '/api/v1/accounts/relationships' const MASTODON_USER_TIMELINE_URL = id => `/api/v1/accounts/${id}/statuses` +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` const MASTODON_TAG_TIMELINE_URL = tag => `/api/v1/timelines/tag/${tag}` const MASTODON_BOOKMARK_TIMELINE_URL = '/api/v1/bookmarks' const MASTODON_USER_BLOCKS_URL = '/api/v1/blocks/' @@ -79,6 +82,7 @@ const MASTODON_UNMUTE_CONVERSATION = id => `/api/v1/statuses/${id}/unmute` const MASTODON_SEARCH_2 = '/api/v2/search' const MASTODON_USER_SEARCH_URL = '/api/v1/accounts/search' const MASTODON_DOMAIN_BLOCKS_URL = '/api/v1/domain_blocks' +const MASTODON_LISTS_URL = '/api/v1/lists' const MASTODON_STREAMING = '/api/v1/streaming' const MASTODON_KNOWN_DOMAIN_LIST_URL = '/api/v1/instance/peers' const PLEROMA_EMOJI_REACTIONS_URL = id => `/api/v1/pleroma/statuses/${id}/reactions` @@ -385,6 +389,81 @@ const fetchFollowRequests = ({ credentials }) => { .then((data) => data.map(parseUser)) } +const fetchLists = ({ credentials }) => { + const url = MASTODON_LISTS_URL + return fetch(url, { headers: authHeaders(credentials) }) + .then((data) => data.json()) +} + +const createList = ({ title, credentials }) => { + const url = MASTODON_LISTS_URL + const headers = authHeaders(credentials) + headers['Content-Type'] = 'application/json' + + return fetch(url, { + headers, + method: 'POST', + body: JSON.stringify({ title }) + }).then((data) => data.json()) +} + +const getList = ({ id, credentials }) => { + const url = MASTODON_LIST_URL(id) + return fetch(url, { headers: authHeaders(credentials) }) + .then((data) => data.json()) +} + +const updateList = ({ id, title, credentials }) => { + const url = MASTODON_LIST_URL(id) + const headers = authHeaders(credentials) + headers['Content-Type'] = 'application/json' + + return fetch(url, { + headers, + method: 'PUT', + body: JSON.stringify({ title }) + }) +} + +const getListAccounts = ({ id, credentials }) => { + const url = MASTODON_LIST_ACCOUNTS_URL(id) + 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 headers = authHeaders(credentials) + headers['Content-Type'] = 'application/json' + + return fetch(url, { + headers, + method: 'POST', + body: JSON.stringify({ account_ids: accountIds }) + }) +} + +const removeAccountsFromList = ({ id, accountIds, credentials }) => { + const url = MASTODON_LIST_ACCOUNTS_URL(id) + const headers = authHeaders(credentials) + headers['Content-Type'] = 'application/json' + + return fetch(url, { + headers, + method: 'DELETE', + body: JSON.stringify({ account_ids: accountIds }) + }) +} + +const deleteList = ({ id, credentials }) => { + const url = MASTODON_LIST_URL(id) + return fetch(url, { + method: 'DELETE', + headers: authHeaders(credentials) + }) +} + const fetchConversation = ({ id, credentials }) => { const urlContext = MASTODON_STATUS_CONTEXT_URL(id) return fetch(urlContext, { headers: authHeaders(credentials) }) @@ -506,6 +585,7 @@ const fetchTimeline = ({ since = false, until = false, userId = false, + listId = false, tag = false, withMuted = false, replyVisibility = 'all' @@ -518,6 +598,7 @@ const fetchTimeline = ({ publicAndExternal: MASTODON_PUBLIC_TIMELINE, user: MASTODON_USER_TIMELINE_URL, media: MASTODON_USER_TIMELINE_URL, + list: MASTODON_LIST_TIMELINE_URL, favorites: MASTODON_USER_FAVORITES_TIMELINE_URL, tag: MASTODON_TAG_TIMELINE_URL, bookmarks: MASTODON_BOOKMARK_TIMELINE_URL @@ -531,6 +612,10 @@ const fetchTimeline = ({ url = url(userId) } + if (timeline === 'list') { + url = url(listId) + } + if (since) { params.push(['since_id', since]) } @@ -1405,6 +1490,14 @@ const apiService = { addBackup, listBackups, fetchFollowRequests, + fetchLists, + createList, + getList, + updateList, + getListAccounts, + addAccountsToList, + removeAccountsFromList, + deleteList, approveUser, denyUser, suggestions, diff --git a/src/services/backend_interactor_service/backend_interactor_service.js b/src/services/backend_interactor_service/backend_interactor_service.js index 4a40f5b5..62ee8549 100644 --- a/src/services/backend_interactor_service/backend_interactor_service.js +++ b/src/services/backend_interactor_service/backend_interactor_service.js @@ -2,10 +2,11 @@ import apiService, { getMastodonSocketURI, ProcessedWS } from '../api/api.servic import timelineFetcher from '../timeline_fetcher/timeline_fetcher.service.js' import notificationsFetcher from '../notifications_fetcher/notifications_fetcher.service.js' import followRequestFetcher from '../../services/follow_request_fetcher/follow_request_fetcher.service' +import listsFetcher from '../../services/lists_fetcher/lists_fetcher.service.js' const backendInteractorService = credentials => ({ - startFetchingTimeline ({ timeline, store, userId = false, tag }) { - return timelineFetcher.startFetching({ timeline, store, credentials, userId, tag }) + startFetchingTimeline ({ timeline, store, userId = false, listId = false, tag }) { + return timelineFetcher.startFetching({ timeline, store, credentials, userId, listId, tag }) }, fetchTimeline (args) { @@ -24,6 +25,10 @@ const backendInteractorService = credentials => ({ return followRequestFetcher.startFetching({ store, credentials }) }, + startFetchingLists ({ store }) { + return listsFetcher.startFetching({ store, credentials }) + }, + startUserSocket ({ store }) { const serv = store.rootState.instance.server.replace('http', 'ws') const url = serv + getMastodonSocketURI({ credentials, stream: 'user' }) diff --git a/src/services/lists_fetcher/lists_fetcher.service.js b/src/services/lists_fetcher/lists_fetcher.service.js new file mode 100644 index 00000000..8d9dae66 --- /dev/null +++ b/src/services/lists_fetcher/lists_fetcher.service.js @@ -0,0 +1,22 @@ +import apiService from '../api/api.service.js' +import { promiseInterval } from '../promise_interval/promise_interval.js' + +const fetchAndUpdate = ({ store, credentials }) => { + return apiService.fetchLists({ credentials }) + .then(lists => { + store.commit('setLists', lists) + }, () => {}) + .catch(() => {}) +} + +const startFetching = ({ credentials, store }) => { + const boundFetchAndUpdate = () => fetchAndUpdate({ credentials, store }) + boundFetchAndUpdate() + return promiseInterval(boundFetchAndUpdate, 240000) +} + +const listsFetcher = { + startFetching +} + +export default listsFetcher diff --git a/src/services/timeline_fetcher/timeline_fetcher.service.js b/src/services/timeline_fetcher/timeline_fetcher.service.js index 7ba138e0..8501907e 100644 --- a/src/services/timeline_fetcher/timeline_fetcher.service.js +++ b/src/services/timeline_fetcher/timeline_fetcher.service.js @@ -3,12 +3,13 @@ import { camelCase } from 'lodash' import apiService from '../api/api.service.js' import { promiseInterval } from '../promise_interval/promise_interval.js' -const update = ({ store, statuses, timeline, showImmediately, userId, pagination }) => { +const update = ({ store, statuses, timeline, showImmediately, userId, listId, pagination }) => { const ccTimeline = camelCase(timeline) store.dispatch('addNewStatuses', { timeline: ccTimeline, userId, + listId, statuses, showImmediately, pagination @@ -22,6 +23,7 @@ const fetchAndUpdate = ({ older = false, showImmediately = false, userId = false, + listId = false, tag = false, until, since @@ -44,6 +46,7 @@ const fetchAndUpdate = ({ } args.userId = userId + args.listId = listId args.tag = tag args.withMuted = !hideMutedPosts if (loggedIn && ['friends', 'public', 'publicAndExternal'].includes(timeline)) { @@ -62,7 +65,7 @@ const fetchAndUpdate = ({ if (!older && statuses.length >= 20 && !timelineData.loading && numStatusesBeforeFetch > 0) { store.dispatch('queueFlush', { timeline, id: timelineData.maxId }) } - update({ store, statuses, timeline, showImmediately, userId, pagination }) + update({ store, statuses, timeline, showImmediately, userId, listId, pagination }) return { statuses, pagination } }) .catch((error) => { @@ -75,14 +78,15 @@ const fetchAndUpdate = ({ }) } -const startFetching = ({ timeline = 'friends', credentials, store, userId = false, tag = false }) => { +const startFetching = ({ timeline = 'friends', credentials, store, userId = false, listId = false, tag = false }) => { const rootState = store.rootState || store.state const timelineData = rootState.statuses.timelines[camelCase(timeline)] const showImmediately = timelineData.visibleStatuses.length === 0 timelineData.userId = userId - fetchAndUpdate({ timeline, credentials, store, showImmediately, userId, tag }) + timelineData.listId = listId + fetchAndUpdate({ timeline, credentials, store, showImmediately, userId, listId, tag }) const boundFetchAndUpdate = () => - fetchAndUpdate({ timeline, credentials, store, userId, tag }) + fetchAndUpdate({ timeline, credentials, store, userId, listId, tag }) return promiseInterval(boundFetchAndUpdate, 10000) } const timelineFetcher = { diff --git a/test/unit/specs/boot/routes.spec.js b/test/unit/specs/boot/routes.spec.js index 5cffefbb..4c387567 100644 --- a/test/unit/specs/boot/routes.spec.js +++ b/test/unit/specs/boot/routes.spec.js @@ -40,4 +40,28 @@ describe('routes', () => { // eslint-disable-next-line no-prototype-builtins expect(matchedComponents[0].components.default.components.hasOwnProperty('UserCard')).to.eql(true) }) + + it('list view', async () => { + await router.push('/lists') + + const matchedComponents = router.currentRoute.value.matched + + expect(matchedComponents[0].components.default.components.hasOwnProperty('ListsCard')).to.eql(true) + }) + + it('list timeline', async () => { + await router.push('/lists/1') + + const matchedComponents = router.currentRoute.value.matched + + expect(matchedComponents[0].components.default.components.hasOwnProperty('Timeline')).to.eql(true) + }) + + it('list edit', async () => { + await router.push('/lists/1/edit') + + const matchedComponents = router.currentRoute.value.matched + + expect(matchedComponents[0].components.default.components.hasOwnProperty('BasicUserCard')).to.eql(true) + }) }) diff --git a/test/unit/specs/modules/lists.spec.js b/test/unit/specs/modules/lists.spec.js new file mode 100644 index 00000000..ac9af1b6 --- /dev/null +++ b/test/unit/specs/modules/lists.spec.js @@ -0,0 +1,83 @@ +import { cloneDeep } from 'lodash' +import { defaultState, mutations, getters } from '../../../../src/modules/lists.js' + +describe('The lists module', () => { + describe('mutations', () => { + it('updates array of all lists', () => { + const state = cloneDeep(defaultState) + const list = { id: '1', title: 'testList' } + + mutations.setLists(state, [list]) + expect(state.allLists).to.have.length(1) + expect(state.allLists).to.eql([list]) + }) + + it('adds a new list with a title, updating the title for existing lists', () => { + const state = cloneDeep(defaultState) + 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 }) + 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 }) + expect(state.allLists).to.have.length(1) + expect(state.allLists[0]).to.eql(modList) + }) + + it('adds a new list with an array of IDs, updating the IDs for existing lists', () => { + const state = cloneDeep(defaultState) + const list = { id: '1', accountIds: ['1', '2', '3'] } + const modList = { id: '1', accountIds: ['3', '4', '5'] } + + mutations.setListAccounts(state, list) + expect(state.allListsObject[list.id]).to.eql({ accountIds: list.accountIds }) + + mutations.setListAccounts(state, modList) + expect(state.allListsObject[modList.id]).to.eql({ accountIds: modList.accountIds }) + }) + + it('deletes a list', () => { + const state = { + allLists: [{ id: '1', title: 'testList' }], + allListsObject: { + 1: { title: 'testList', accountIds: ['1', '2', '3'] } + } + } + const id = '1' + + mutations.deleteList(state, { id }) + expect(state.allLists).to.have.length(0) + expect(state.allListsObject).to.eql({}) + }) + }) + + describe('getters', () => { + it('returns list title', () => { + const state = { + allLists: [{ id: '1', title: 'testList' }], + allListsObject: { + 1: { title: 'testList', accountIds: ['1', '2', '3'] } + } + } + const id = '1' + + expect(getters.findListTitle(state)(id)).to.eql('testList') + }) + + it('returns list accounts', () => { + const state = { + allLists: [{ id: '1', title: 'testList' }], + allListsObject: { + 1: { title: 'testList', accountIds: ['1', '2', '3'] } + } + } + const id = '1' + + expect(getters.findListAccounts(state)(id)).to.eql(['1', '2', '3']) + }) + }) +})