diff --git a/src/boot/routes.js b/src/boot/routes.js index 726476a8..1ab8209d 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 ListTimeline from 'components/list_timeline/list_timeline.vue' +import ListEdit from 'components/list_edit/list_edit.vue' export default (store) => { const validateAuthenticatedRoute = (to, from, next) => { @@ -69,7 +72,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: ListTimeline }, + { name: 'list-edit', path: '/lists/:id/edit', component: ListEdit } ] if (store.state.instance.pleromaChatMessagesAvailable) { diff --git a/src/components/list_card/list_card.js b/src/components/list_card/list_card.js new file mode 100644 index 00000000..4668db0e --- /dev/null +++ b/src/components/list_card/list_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 ListCard = { + props: [ + 'list' + ] +} + +export default ListCard diff --git a/src/components/list_card/list_card.vue b/src/components/list_card/list_card.vue new file mode 100644 index 00000000..7d0df69c --- /dev/null +++ b/src/components/list_card/list_card.vue @@ -0,0 +1,51 @@ + + + + + diff --git a/src/components/list_edit/list_edit.js b/src/components/list_edit/list_edit.js new file mode 100644 index 00000000..f982f4d4 --- /dev/null +++ b/src/components/list_edit/list_edit.js @@ -0,0 +1,109 @@ +import { mapState, mapGetters } from 'vuex' +import BasicUserCard from '../basic_user_card/basic_user_card.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 ListNew = { + components: { + BasicUserCard, + UserAvatar + }, + data () { + return { + title: '', + userIds: [], + selectedUserIds: [], + loading: false, + query: '' + } + }, + 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) + }, + availableUsers () { + if (this.query.length !== 0) { + return this.users + } else { + return this.selectedUsers + } + }, + ...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) + }, + search (query) { + if (!query) { + this.loading = false + return + } + + this.loading = true + this.userIds = [] + this.$store.dispatch('search', { q: query, resolve: true, type: 'accounts', following: true }) + .then(data => { + this.loading = false + this.userIds = data.accounts.map(a => a.id) + }) + }, + 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 ListNew diff --git a/src/components/list_edit/list_edit.vue b/src/components/list_edit/list_edit.vue new file mode 100644 index 00000000..98704062 --- /dev/null +++ b/src/components/list_edit/list_edit.vue @@ -0,0 +1,108 @@ + + + + + diff --git a/src/components/list_new/list_new.js b/src/components/list_new/list_new.js new file mode 100644 index 00000000..e3e4aef0 --- /dev/null +++ b/src/components/list_new/list_new.js @@ -0,0 +1,97 @@ +import { mapState, mapGetters } from 'vuex' +import BasicUserCard from '../basic_user_card/basic_user_card.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 ListNew = { + components: { + BasicUserCard, + UserAvatar + }, + data () { + return { + title: '', + userIds: [], + selectedUserIds: [], + loading: false, + query: '' + } + }, + computed: { + users () { + return this.userIds.map(userId => this.findUser(userId)) + }, + selectedUsers () { + return this.selectedUserIds.map(userId => this.findUser(userId)) + }, + availableUsers () { + if (this.query.length !== 0) { + return this.users + } else { + return this.selectedUsers + } + }, + ...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) + }, + search (query) { + if (!query) { + this.loading = false + return + } + + this.loading = true + this.userIds = [] + this.$store.dispatch('search', { q: query, resolve: true, type: 'accounts', following: true }) + .then(data => { + this.loading = false + this.userIds = data.accounts.map(a => a.id) + }) + }, + 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 ListNew diff --git a/src/components/list_new/list_new.vue b/src/components/list_new/list_new.vue new file mode 100644 index 00000000..9bd7c5a5 --- /dev/null +++ b/src/components/list_new/list_new.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/src/components/list_timeline/list_timeline.js b/src/components/list_timeline/list_timeline.js new file mode 100644 index 00000000..e17abb32 --- /dev/null +++ b/src/components/list_timeline/list_timeline.js @@ -0,0 +1,25 @@ +import Timeline from '../timeline/timeline.vue' +const ListTimeline = { + data () { + return { + listId: null + } + }, + components: { + Timeline + }, + computed: { + timeline () { return this.$store.state.statuses.timelines.list } + }, + 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 ListTimeline diff --git a/src/components/list_timeline/list_timeline.vue b/src/components/list_timeline/list_timeline.vue new file mode 100644 index 00000000..a368f861 --- /dev/null +++ b/src/components/list_timeline/list_timeline.vue @@ -0,0 +1,10 @@ + + + diff --git a/src/components/lists/lists.js b/src/components/lists/lists.js new file mode 100644 index 00000000..09c49407 --- /dev/null +++ b/src/components/lists/lists.js @@ -0,0 +1,32 @@ +import ListCard from '../list_card/list_card.vue' +import ListNew from '../list_new/list_new.vue' + +const Lists = { + data () { + return { + isNew: false + } + }, + components: { + ListCard, + ListNew + }, + 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..f11a2a02 --- /dev/null +++ b/src/components/lists/lists.vue @@ -0,0 +1,31 @@ + + + diff --git a/src/components/nav_panel/nav_panel.js b/src/components/nav_panel/nav_panel.js index 37bcb409..f52fc677 100644 --- a/src/components/nav_panel/nav_panel.js +++ b/src/components/nav_panel/nav_panel.js @@ -12,7 +12,8 @@ import { faComments, faBell, faInfoCircle, - faStream + faStream, + faList } from '@fortawesome/free-solid-svg-icons' library.add( @@ -25,7 +26,8 @@ library.add( faComments, faBell, faInfoCircle, - faStream + faStream, + faList ) const NavPanel = { diff --git a/src/components/nav_panel/nav_panel.vue b/src/components/nav_panel/nav_panel.vue index 7ae7b1d6..c139549d 100644 --- a/src/components/nav_panel/nav_panel.vue +++ b/src/components/nav_panel/nav_panel.vue @@ -25,6 +25,18 @@ +
  • + + {{ $t("nav.lists") }} + +
  • {{ $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 bab51e75..d152c0fe 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 ec2882c5..3430620b 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…", @@ -946,6 +947,15 @@ "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" + }, "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 54f94356..e9bf8c46 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/lists.js b/src/modules/lists.js new file mode 100644 index 00000000..0f751671 --- /dev/null +++ b/src/modules/lists.js @@ -0,0 +1,90 @@ +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 }) { + commit('setListAccounts', { id, accountIds }) + rootState.api.backendInteractor.addAccountsToList({ id, accountIds }) + rootState.api.backendInteractor.removeAccountsFromList({ + id, + accountIds: rootState.lists.allListsObject[id].accountIds.filter(id => !accountIds.includes(id)) + }) + }, + 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 a13930e9..ea48f2d6 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 e7a64337..fa3439e9 100644 --- a/src/services/api/api.service.js +++ b/src/services/api/api.service.js @@ -50,6 +50,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/' @@ -78,6 +81,7 @@ const MASTODON_SEARCH_2 = `/api/v2/search` const MASTODON_USER_SEARCH_URL = '/api/v1/accounts/search' const MASTODON_MASCOT_URL = '/api/v1/pleroma/mascot' 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` @@ -382,6 +386,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, { + method: 'POST', + headers: headers, + 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, { + method: 'PUT', + headers: headers, + 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, { + method: 'POST', + headers: headers, + 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, { + method: 'DELETE', + headers: headers, + 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 }) => { let urlContext = MASTODON_STATUS_CONTEXT_URL(id) return fetch(urlContext, { headers: authHeaders(credentials) }) @@ -503,6 +582,7 @@ const fetchTimeline = ({ since = false, until = false, userId = false, + listId = false, tag = false, withMuted = false, replyVisibility = 'all' @@ -515,6 +595,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 @@ -528,6 +609,10 @@ const fetchTimeline = ({ url = url(userId) } + if (timeline === 'list') { + url = url(listId) + } + if (since) { params.push(['since_id', since]) } @@ -1348,6 +1433,14 @@ const apiService = { mfaSetupOTP, mfaConfirmOTP, 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 3ada329b..49d7cdc8 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: 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, 20000) } const timelineFetcher = { diff --git a/test/unit/specs/boot/routes.spec.js b/test/unit/specs/boot/routes.spec.js index 439aefd4..d4cde702 100644 --- a/test/unit/specs/boot/routes.spec.js +++ b/test/unit/specs/boot/routes.spec.js @@ -37,4 +37,28 @@ describe('routes', () => { 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('ListCard')).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']) + }) + }) +})