From 8c59bad3c2444e7deea20f9d301b675d2ef51016 Mon Sep 17 00:00:00 2001 From: Henry Jameson Date: Thu, 4 Aug 2022 22:09:42 +0300 Subject: unit test + some refactoring --- test/unit/specs/modules/serverSideStorage.spec.js | 178 ++++++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 test/unit/specs/modules/serverSideStorage.spec.js (limited to 'test/unit/specs') diff --git a/test/unit/specs/modules/serverSideStorage.spec.js b/test/unit/specs/modules/serverSideStorage.spec.js new file mode 100644 index 00000000..e06c6ada --- /dev/null +++ b/test/unit/specs/modules/serverSideStorage.spec.js @@ -0,0 +1,178 @@ +import { cloneDeep } from 'lodash' + +import { + VERSION, + COMMAND_TRIM_FLAGS, + COMMAND_TRIM_FLAGS_AND_RESET, + _getRecentData, + _getAllFlags, + _mergeFlags, + _resetFlags, + mutations, + defaultState, + newUserFlags +} from 'src/modules/serverSideStorage.js' + +describe('The serverSideStorage module', () => { + describe('mutations', () => { + describe('setServerSideStorage', () => { + const { setServerSideStorage } = mutations + const user = { + created_at: new Date('1999-02-09'), + storage: {} + } + + it('should initialize storage if none present', () => { + const state = cloneDeep(defaultState) + setServerSideStorage(state, user) + expect(state.cache._version).to.eql(VERSION) + expect(state.cache._timestamp).to.be.a('number') + expect(state.cache.flagStorage).to.eql(defaultState.flagStorage) + }) + + it('should initialize storage with proper flags for new users if none present', () => { + const state = cloneDeep(defaultState) + setServerSideStorage(state, { ...user, created_at: new Date() }) + expect(state.cache._version).to.eql(VERSION) + expect(state.cache._timestamp).to.be.a('number') + expect(state.cache.flagStorage).to.eql(newUserFlags) + }) + + it('should merge flags even if remote timestamp is older', () => { + const state = { + ...cloneDeep(defaultState), + cache: { + _timestamp: Date.now(), + _version: VERSION, + ...cloneDeep(defaultState) + } + } + setServerSideStorage( + state, + { + ...user, + storage: { + _timestamp: 123, + _version: VERSION, + flagStorage: { + ...defaultState.flagStorage, + updateCounter: 1 + } + } + } + ) + expect(state.cache.flagStorage).to.eql({ + ...defaultState.flagStorage, + updateCounter: 1 + }) + }) + + it('should reset local timestamp to remote if contents are the same', () => { + const state = { + ...cloneDeep(defaultState), + cache: null + } + setServerSideStorage( + state, + { + ...user, + storage: { + _timestamp: 123, + _version: VERSION, + flagStorage: { + ...defaultState.flagStorage, + updateCounter: 999 + } + } + } + ) + expect(state.cache._timestamp).to.eql(123) + expect(state.flagStorage.updateCounter).to.eql(999) + expect(state.cache.flagStorage.updateCounter).to.eql(999) + }) + + it('should remote version if local missing', () => { + const state = cloneDeep(defaultState) + setServerSideStorage(state, user) + expect(state.cache._version).to.eql(VERSION) + expect(state.cache._timestamp).to.be.a('number') + expect(state.cache.flagStorage).to.eql(defaultState.flagStorage) + }) + }) + }) + + describe('helper functions', () => { + describe('_getRecentData', () => { + it('should handle nulls correctly', () => { + expect(_getRecentData(null, null)).to.eql({ recent: null, stale: null, needUpload: true }) + }) + + it('doesn\'t choke on invalid data', () => { + expect(_getRecentData({ a: 1 }, { b: 2 })).to.eql({ recent: null, stale: null, needUpload: true }) + }) + + it('should prefer the valid non-null correctly, needUpload works properly', () => { + const nonNull = { _version: VERSION, _timestamp: 1 } + expect(_getRecentData(nonNull, null)).to.eql({ recent: nonNull, stale: null, needUpload: true }) + expect(_getRecentData(null, nonNull)).to.eql({ recent: nonNull, stale: null, needUpload: false }) + }) + + it('should prefer the one with higher timestamp', () => { + const a = { _version: VERSION, _timestamp: 1 } + const b = { _version: VERSION, _timestamp: 2 } + + expect(_getRecentData(a, b)).to.eql({ recent: b, stale: a, needUpload: false }) + expect(_getRecentData(b, a)).to.eql({ recent: b, stale: a, needUpload: false }) + }) + + it('case where both are same', () => { + const a = { _version: VERSION, _timestamp: 3 } + const b = { _version: VERSION, _timestamp: 3 } + + expect(_getRecentData(a, b)).to.eql({ recent: b, stale: a, needUpload: false }) + expect(_getRecentData(b, a)).to.eql({ recent: b, stale: a, needUpload: false }) + }) + }) + + describe('_getAllFlags', () => { + it('should handle nulls properly', () => { + expect(_getAllFlags(null, null)).to.eql([]) + }) + it('should output list of keys if passed single object', () => { + expect(_getAllFlags({ flagStorage: { a: 1, b: 1, c: 1 } }, null)).to.eql(['a', 'b', 'c']) + }) + it('should union keys of both objects', () => { + expect(_getAllFlags({ flagStorage: { a: 1, b: 1, c: 1 } }, { flagStorage: { c: 1, d: 1 } })).to.eql(['a', 'b', 'c', 'd']) + }) + }) + + describe('_mergeFlags', () => { + it('should handle merge two flag sets correctly picking higher numbers', () => { + expect( + _mergeFlags( + { flagStorage: { a: 0, b: 3 } }, + { flagStorage: { b: 1, c: 4, d: 9 } }, + ['a', 'b', 'c', 'd']) + ).to.eql({ a: 0, b: 3, c: 4, d: 9 }) + }) + }) + + 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 } + + expect(_resetFlags(totalFlags)).to.eql({ a: 0, b: 0, reset: 0 }) + }) + it('should trim all flags to known when reset is set to 1000', () => { + const totalFlags = { a: 0, b: 3, c: 33, reset: COMMAND_TRIM_FLAGS } + + expect(_resetFlags(totalFlags, { a: 0, b: 0, reset: 0 })).to.eql({ a: 0, b: 3, reset: 0 }) + }) + it('should trim all flags to known and reset when reset is set to 1001', () => { + const totalFlags = { a: 0, b: 3, c: 33, reset: COMMAND_TRIM_FLAGS_AND_RESET } + + expect(_resetFlags(totalFlags, { a: 0, b: 0, reset: 0 })).to.eql({ a: 0, b: 0, reset: 0 }) + }) + }) + }) +}) -- cgit v1.2.3-70-g09d2 From 171f6f08943dd1d87120df3e4894ddcfd5e1d246 Mon Sep 17 00:00:00 2001 From: Alexander Tumin Date: Sat, 6 Aug 2022 17:26:43 +0300 Subject: 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 +++++++++++++++++++++ src/components/lists_menu/lists_menu_content.js | 33 +++++++ src/components/lists_menu/lists_menu_content.vue | 17 ++++ src/components/lists_new/lists_new.js | 79 +++++++++++++++ src/components/lists_new/lists_new.vue | 95 ++++++++++++++++++ src/components/lists_timeline/lists_timeline.js | 36 +++++++ src/components/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 +++++++++ src/components/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/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 (limited to 'test/unit/specs') 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']) + }) + }) +}) -- cgit v1.2.3-70-g09d2 From b5eba5974ca86cdb0b5f3caaa9c89466a07d8449 Mon Sep 17 00:00:00 2001 From: Alexander Tumin Date: Sat, 6 Aug 2022 18:03:04 +0300 Subject: Lists implementation: tests, linter fix --- src/modules/lists.js | 2 +- test/unit/specs/boot/routes.spec.js | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) (limited to 'test/unit/specs') diff --git a/src/modules/lists.js b/src/modules/lists.js index 58700f41..84c15759 100644 --- a/src/modules/lists.js +++ b/src/modules/lists.js @@ -57,7 +57,7 @@ const actions = { commit('setList', { id, title }) }, setListAccounts ({ rootState, commit }, { id, accountIds }) { - const saved = rootState.lists.allListsObject[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 }) diff --git a/test/unit/specs/boot/routes.spec.js b/test/unit/specs/boot/routes.spec.js index 4c387567..ff246d2b 100644 --- a/test/unit/specs/boot/routes.spec.js +++ b/test/unit/specs/boot/routes.spec.js @@ -46,7 +46,7 @@ describe('routes', () => { const matchedComponents = router.currentRoute.value.matched - expect(matchedComponents[0].components.default.components.hasOwnProperty('ListsCard')).to.eql(true) + expect(Object.prototype.hasOwnProperty.call(matchedComponents[0].components.default.components, 'ListsCard')).to.eql(true) }) it('list timeline', async () => { @@ -54,7 +54,7 @@ describe('routes', () => { const matchedComponents = router.currentRoute.value.matched - expect(matchedComponents[0].components.default.components.hasOwnProperty('Timeline')).to.eql(true) + expect(Object.prototype.hasOwnProperty.call(matchedComponents[0].components.default.components, 'Timeline')).to.eql(true) }) it('list edit', async () => { @@ -62,6 +62,6 @@ describe('routes', () => { const matchedComponents = router.currentRoute.value.matched - expect(matchedComponents[0].components.default.components.hasOwnProperty('BasicUserCard')).to.eql(true) + expect(Object.prototype.hasOwnProperty.call(matchedComponents[0].components.default.components, 'BasicUserCard')).to.eql(true) }) }) -- cgit v1.2.3-70-g09d2 From aa41cedd932e88b030ecc3cc54848b5aa300eec3 Mon Sep 17 00:00:00 2001 From: Henry Jameson Date: Wed, 10 Aug 2022 02:19:07 +0300 Subject: initial prefs storage work --- src/modules/serverSideStorage.js | 54 ++++++++++++- test/unit/specs/modules/serverSideStorage.spec.js | 94 +++++++++++++++++++++++ 2 files changed, 144 insertions(+), 4 deletions(-) (limited to 'test/unit/specs') diff --git a/src/modules/serverSideStorage.js b/src/modules/serverSideStorage.js index e516a6e6..08ba48bb 100644 --- a/src/modules/serverSideStorage.js +++ b/src/modules/serverSideStorage.js @@ -1,5 +1,5 @@ import { toRaw } from 'vue' -import { isEqual, cloneDeep } from 'lodash' +import { isEqual, uniqBy, cloneDeep, set } from 'lodash' import { CURRENT_UPDATE_COUNTER } from 'src/components/update_notification/update_notification.js' export const VERSION = 1 @@ -22,6 +22,10 @@ export const defaultState = { // 1000: trim keys to those known by currently running FE // 1001: same as above + reset everything to 0 }, + prefsStorage: { + _journal: [], + simple: {} + }, // raw data raw: null, // local cache @@ -93,6 +97,42 @@ export const _mergeFlags = (recent, stale, allFlagKeys) => { })) } +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 = uniqBy( + [...recentJournal, ...staleJournal].sort((a, b) => a.timestamp > b.timestamp ? -1 : 1), + 'path' + ).reverse() + totalJournal.forEach(({ path, timestamp, operation, 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 + 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) @@ -165,7 +205,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 +221,21 @@ 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) + totalPrefs = _mergePrefs(recent.prefsStorage, stale.prefsStorage) } else { totalFlags = recent.flagStorage + totalPrefs = recent.prefsStorage } totalFlags = _resetFlags(totalFlags) recent.flagStorage = totalFlags + recent.prefsStorage = totalPrefs state.dirty = dirty || needsUpload state.cache = recent @@ -216,7 +261,8 @@ const serverSideStorage = { const needPush = state.dirty || force if (!needPush) return state.cache = _wrapData({ - flagStorage: toRaw(state.flagStorage) + flagStorage: toRaw(state.flagStorage), + prefsStorage: toRaw(state.flagStorage) }) const params = { pleroma_settings_store: { 'pleroma-fe': state.cache } } rootState.api.backendInteractor diff --git a/test/unit/specs/modules/serverSideStorage.spec.js b/test/unit/specs/modules/serverSideStorage.spec.js index e06c6ada..6059dc84 100644 --- a/test/unit/specs/modules/serverSideStorage.spec.js +++ b/test/unit/specs/modules/serverSideStorage.spec.js @@ -7,6 +7,7 @@ import { _getRecentData, _getAllFlags, _mergeFlags, + _mergePrefs, _resetFlags, mutations, defaultState, @@ -28,6 +29,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 +38,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 +60,9 @@ describe('The serverSideStorage module', () => { flagStorage: { ...defaultState.flagStorage, updateCounter: 1 + }, + prefsStorage: { + ...defaultState.flagStorage, } } } @@ -157,6 +163,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 } -- cgit v1.2.3-70-g09d2 From 2c0eb29b286edf57d75c6044855ea5be9187493b Mon Sep 17 00:00:00 2001 From: Henry Jameson Date: Wed, 10 Aug 2022 02:31:41 +0300 Subject: more prefs storage work + move dontShowUpdateNotifs to prefs --- .../update_notification/update_notification.js | 4 +-- src/modules/serverSideStorage.js | 23 ++++++++++++---- test/unit/specs/modules/serverSideStorage.spec.js | 32 ++++++++++++++++++++++ 3 files changed, 52 insertions(+), 7 deletions(-) (limited to 'test/unit/specs') diff --git a/src/components/update_notification/update_notification.js b/src/components/update_notification/update_notification.js index ba008d81..609842c4 100644 --- a/src/components/update_notification/update_notification.js +++ b/src/components/update_notification/update_notification.js @@ -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 () { diff --git a/src/modules/serverSideStorage.js b/src/modules/serverSideStorage.js index 08ba48bb..bb647b97 100644 --- a/src/modules/serverSideStorage.js +++ b/src/modules/serverSideStorage.js @@ -14,9 +14,6 @@ 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 @@ -24,7 +21,9 @@ export const defaultState = { }, prefsStorage: { _journal: [], - simple: {} + simple: { + dontShowUpdateNotifs: false + } }, // raw data raw: null, @@ -248,6 +247,20 @@ export const mutations = { 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 = uniqBy( + [ + ...state.prefsStorage._journal, + { command: 'set', path, args: [value], timestamp: Date.now() } + ].sort((a, b) => a.timestamp > b.timestamp ? -1 : 1), + 'path' + ).reverse() } } @@ -262,7 +275,7 @@ const serverSideStorage = { if (!needPush) return state.cache = _wrapData({ flagStorage: toRaw(state.flagStorage), - prefsStorage: toRaw(state.flagStorage) + prefsStorage: toRaw(state.prefsStorage) }) const params = { pleroma_settings_store: { 'pleroma-fe': state.cache } } rootState.api.backendInteractor diff --git a/test/unit/specs/modules/serverSideStorage.spec.js b/test/unit/specs/modules/serverSideStorage.spec.js index 6059dc84..da790793 100644 --- a/test/unit/specs/modules/serverSideStorage.spec.js +++ b/test/unit/specs/modules/serverSideStorage.spec.js @@ -105,6 +105,38 @@ describe('The serverSideStorage module', () => { expect(state.cache.flagStorage).to.eql(defaultState.flagStorage) }) }) + describe('setPreference', () => { + const { setPreference } = 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', + command: '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 (one entry per path)', () => { + const state = cloneDeep(defaultState) + setPreference(state, { path: 'simple.testing', value: 1 }) + setPreference(state, { path: 'simple.testing', value: 2 }) + 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', + command: 'set', + args: [2], + // should have A timestamp, we don't really care what it is + timestamp: state.prefsStorage._journal[0].timestamp + }) + }) + }) }) describe('helper functions', () => { -- cgit v1.2.3-70-g09d2 From ab4a75bdd92aba7973b6c32eb8ff11280552d6c6 Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Wed, 10 Aug 2022 12:17:18 -0400 Subject: Do not allow to find by name in findUser() --- src/components/user_profile/user_profile.js | 2 +- src/modules/users.js | 20 +++++------ test/unit/specs/components/user_profile.spec.js | 5 ++- test/unit/specs/modules/users.spec.js | 44 ++++++++++++++++++++++--- 4 files changed, 54 insertions(+), 17 deletions(-) (limited to 'test/unit/specs') diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js index 781fd601..08adaeab 100644 --- a/src/components/user_profile/user_profile.js +++ b/src/components/user_profile/user_profile.js @@ -110,7 +110,7 @@ const UserProfile = { const maybeName = userNameOrId.name // Check if user data is already loaded in store - const user = this.$store.getters.findUser(maybeId || maybeName) + const user = maybeId ? this.$store.getters.findUser(maybeId) : this.$store.getters.findUserByName(maybeName) if (user) { loadById(user.id) } else { diff --git a/src/modules/users.js b/src/modules/users.js index 47dc8493..be0bc997 100644 --- a/src/modules/users.js +++ b/src/modules/users.js @@ -16,9 +16,6 @@ export const mergeOrAdd = (arr, obj, item) => { // This is a new item, prepare it arr.push(item) obj[item.id] = item - if (item.screen_name && !item.screen_name.includes('@')) { - obj[item.screen_name.toLowerCase()] = item - } return { item, new: true } } } @@ -162,7 +159,11 @@ export const mutations = { if (user.relationship) { state.relationships[user.relationship.id] = user.relationship } - mergeOrAdd(state.users, state.usersObject, user) + const res = mergeOrAdd(state.users, state.usersObject, user) + const item = res.item + if (res.new && item.screen_name && !item.screen_name.includes('@')) { + state.usersByNameObject[item.screen_name.toLowerCase()] = item + } }) }, updateUserRelationship (state, relationships) { @@ -239,12 +240,10 @@ export const mutations = { export const getters = { findUser: state => query => { - const result = state.usersObject[query] - // In case it's a screen_name, we can try searching case-insensitive - if (!result && typeof query === 'string') { - return state.usersObject[query.toLowerCase()] - } - return result + return state.usersObject[query] + }, + findUserByName: state => query => { + return state.usersByNameObject[query.toLowerCase()] }, findUserByUrl: state => query => { return state.users @@ -263,6 +262,7 @@ export const defaultState = { currentUser: false, users: [], usersObject: {}, + usersByNameObject: {}, signUpPending: false, signUpErrors: [], relationships: {} diff --git a/test/unit/specs/components/user_profile.spec.js b/test/unit/specs/components/user_profile.spec.js index 0fbab722..dc0b938a 100644 --- a/test/unit/specs/components/user_profile.spec.js +++ b/test/unit/specs/components/user_profile.spec.js @@ -15,6 +15,7 @@ const actions = { const testGetters = { findUser: state => getters.findUser(state.users), + findUserByName: state => getters.findUserByName(state.users), relationship: state => getters.relationship(state.users), mergedConfig: state => ({ colors: '', @@ -95,6 +96,7 @@ const externalProfileStore = createStore({ credentials: '' }, usersObject: { 100: extUser }, + usersByNameObject: {}, users: [extUser], relationships: {} } @@ -163,7 +165,8 @@ const localProfileStore = createStore({ currentUser: { credentials: '' }, - usersObject: { 100: localUser, testuser: localUser }, + usersObject: { 100: localUser }, + usersByNameObject: { testuser: localUser }, users: [localUser], relationships: {} } diff --git a/test/unit/specs/modules/users.spec.js b/test/unit/specs/modules/users.spec.js index dfa5684d..3073f507 100644 --- a/test/unit/specs/modules/users.spec.js +++ b/test/unit/specs/modules/users.spec.js @@ -57,24 +57,27 @@ describe('The users module', () => { }) describe('findUser', () => { - it('returns user with matching screen_name', () => { + it('does not return user with matching screen_name', () => { const user = { screen_name: 'Guy', id: '1' } const state = { usersObject: { - 1: user, + 1: user + }, + usersByNameObject: { guy: user } } const name = 'Guy' - const expected = { screen_name: 'Guy', id: '1' } - expect(getters.findUser(state)(name)).to.eql(expected) + expect(getters.findUser(state)(name)).to.eql(undefined) }) it('returns user with matching id', () => { const user = { screen_name: 'Guy', id: '1' } const state = { usersObject: { - 1: user, + 1: user + }, + usersByNameObject: { guy: user } } @@ -83,4 +86,35 @@ describe('The users module', () => { expect(getters.findUser(state)(id)).to.eql(expected) }) }) + + describe('findUserByName', () => { + it('returns user with matching screen_name', () => { + const user = { screen_name: 'Guy', id: '1' } + const state = { + usersObject: { + 1: user + }, + usersByNameObject: { + guy: user + } + } + const name = 'Guy' + const expected = { screen_name: 'Guy', id: '1' } + expect(getters.findUserByName(state)(name)).to.eql(expected) + }) + + it('does not return user with matching id', () => { + const user = { screen_name: 'Guy', id: '1' } + const state = { + usersObject: { + 1: user + }, + usersByNameObject: { + guy: user + } + } + const id = '1' + expect(getters.findUserByName(state)(id)).to.eql(undefined) + }) + }) }) -- cgit v1.2.3-70-g09d2 From 72e238ceb34304cb023a01a84c3f453aadaa775c Mon Sep 17 00:00:00 2001 From: Henry Jameson Date: Thu, 11 Aug 2022 01:07:51 +0300 Subject: server side storage support for collections + fixes --- src/modules/serverSideStorage.js | 111 ++++++++++++++++++---- test/unit/specs/modules/serverSideStorage.spec.js | 20 +++- 2 files changed, 108 insertions(+), 23 deletions(-) (limited to 'test/unit/specs') diff --git a/src/modules/serverSideStorage.js b/src/modules/serverSideStorage.js index 1a7e02b3..11e66220 100644 --- a/src/modules/serverSideStorage.js +++ b/src/modules/serverSideStorage.js @@ -1,5 +1,5 @@ import { toRaw } from 'vue' -import { isEqual, uniqBy, cloneDeep, set } from 'lodash' +import { isEqual, uniqWith, cloneDeep, set, get, clamp } from 'lodash' import { CURRENT_UPDATE_COUNTER } from 'src/components/update_notification/update_notification.js' export const VERSION = 1 @@ -36,6 +36,17 @@ export const newUserFlags = { updateCounter: CURRENT_UPDATE_COUNTER // new users don't need to see update notification } +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) => ({ ...data, _timestamp: Date.now(), @@ -98,6 +109,23 @@ export const _mergeFlags = (recent, stale, allFlagKeys) => { })) } +const _mergeJournal = (a, b) => uniqWith( + [ + ...(Array.isArray(a) ? a : []), + ...(Array.isArray(b) ? b : []) + ].sort((a, b) => a.timestamp > b.timestamp ? -1 : 1), + (a, b) => { + if (a.operation !== b.operation) return false + switch (a.operation) { + case 'set': + case 'arrangeSet': + return a.path === b.path + default: + return a.path === b.path && a.timestamp === b.timestamp + } + } +).reverse() + export const _mergePrefs = (recent, stale, allFlagKeys) => { if (!stale) return recent if (!recent) return stale @@ -114,13 +142,7 @@ export const _mergePrefs = (recent, stale, allFlagKeys) => { * shouldn't be used with collections! */ const resultOutput = { ...recentData } - const totalJournal = uniqBy( - [ - ...(Array.isArray(recentJournal) ? recentJournal : []), - ...(Array.isArray(staleJournal) ? staleJournal : []) - ].sort((a, b) => a.timestamp > b.timestamp ? -1 : 1), - 'path' - ).reverse() + const totalJournal = _mergeJournal(staleJournal, recentJournal) totalJournal.forEach(({ path, timestamp, operation, args }) => { if (path.startsWith('_')) { console.error(`journal contains entry to edit internal (starts with _) field '${path}', something is incorrect here, ignoring.`) @@ -130,6 +152,17 @@ export const _mergePrefs = (recent, stale, allFlagKeys) => { 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': + set(resultOutput, path, Array.from(new Set(get(resultOutput, path)).remove(args[0]))) + 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?`) } @@ -260,13 +293,56 @@ export const mutations = { return } set(state.prefsStorage, path, value) - state.prefsStorage._journal = uniqBy( - [ - ...state.prefsStorage._journal, - { command: 'set', path, args: [value], timestamp: Date.now() } - ].sort((a, b) => a.timestamp > b.timestamp ? -1 : 1), - 'path' - ).reverse() + state.prefsStorage._journal = [ + ...state.prefsStorage._journal, + { command: 'set', path, args: [value], timestamp: Date.now() } + ] + }, + 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, + { command: 'addToCollection', path, args: [value], timestamp: Date.now() } + ] + }, + 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.remove(value) + set(state.prefsStorage, path, collection) + state.prefsStorage._journal = [ + ...state.prefsStorage._journal, + { command: 'removeFromCollection', path, args: [value], timestamp: Date.now() } + ] + }, + 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, + { command: 'arrangeCollection', path, args: [value], timestamp: Date.now() } + ] + }, + updateCache (state) { + state.prefsStorage._journal = _mergeJournal(state.prefsStorage._journal) + state.cache = _wrapData({ + flagStorage: toRaw(state.flagStorage), + prefsStorage: toRaw(state.prefsStorage) + }) } } @@ -279,10 +355,7 @@ const serverSideStorage = { pushServerSideStorage ({ state, rootState, commit }, { force = false } = {}) { const needPush = state.dirty || force if (!needPush) return - state.cache = _wrapData({ - flagStorage: toRaw(state.flagStorage), - prefsStorage: toRaw(state.prefsStorage) - }) + commit('updateCache') const params = { pleroma_settings_store: { 'pleroma-fe': state.cache } } rootState.api.backendInteractor .updateProfile({ params }) diff --git a/test/unit/specs/modules/serverSideStorage.spec.js b/test/unit/specs/modules/serverSideStorage.spec.js index da790793..ada3b7ca 100644 --- a/test/unit/specs/modules/serverSideStorage.spec.js +++ b/test/unit/specs/modules/serverSideStorage.spec.js @@ -4,6 +4,7 @@ import { VERSION, COMMAND_TRIM_FLAGS, COMMAND_TRIM_FLAGS_AND_RESET, + _moveItemInArray, _getRecentData, _getAllFlags, _mergeFlags, @@ -62,7 +63,7 @@ describe('The serverSideStorage module', () => { updateCounter: 1 }, prefsStorage: { - ...defaultState.flagStorage, + ...defaultState.prefsStorage } } } @@ -106,7 +107,7 @@ describe('The serverSideStorage module', () => { }) }) describe('setPreference', () => { - const { setPreference } = mutations + const { setPreference, updateCache } = mutations it('should set preference and update journal log accordingly', () => { const state = cloneDeep(defaultState) @@ -122,11 +123,12 @@ describe('The serverSideStorage module', () => { }) }) - it('should keep journal to a minimum (one entry per path)', () => { + it('should keep journal to a minimum (one entry per path for sets)', () => { const state = cloneDeep(defaultState) setPreference(state, { path: 'simple.testing', value: 1 }) setPreference(state, { path: 'simple.testing', value: 2 }) - expect(state.prefsStorage.simple.testing).to.eql(1) + updateCache(state) + expect(state.prefsStorage.simple.testing).to.eql(2) expect(state.prefsStorage._journal.length).to.eql(1) expect(state.prefsStorage._journal[0]).to.eql({ path: 'simple.testing', @@ -140,6 +142,16 @@ describe('The serverSideStorage module', () => { }) 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 }) -- cgit v1.2.3-70-g09d2 From 7742b1987bebe62c270aec6f26834f567016c0b2 Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Wed, 10 Aug 2022 23:01:23 -0400 Subject: Fix test errors --- .../services/entity_normalizer/entity_normalizer.spec.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) (limited to 'test/unit/specs') diff --git a/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js b/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js index 525e57a5..98bb05a8 100644 --- a/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js +++ b/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js @@ -195,7 +195,7 @@ describe('API Entities normalizer', () => { expect(parsedPost).to.have.property('type', 'status') expect(parsedRepeat).to.have.property('type', 'retweet') expect(parsedRepeat).to.have.property('retweeted_status') - expect(parsedRepeat).to.have.deep.property('retweeted_status.id', 'deadbeef') + expect(parsedRepeat).to.have.nested.property('retweeted_status.id', 'deadbeef') }) it('sets nsfw for statuses with the #nsfw tag', () => { @@ -229,7 +229,7 @@ describe('API Entities normalizer', () => { expect(parsedPost).to.have.property('type', 'status') expect(parsedRepeat).to.have.property('type', 'retweet') expect(parsedRepeat).to.have.property('retweeted_status') - expect(parsedRepeat).to.have.deep.property('retweeted_status.id', 'deadbeef') + expect(parsedRepeat).to.have.nested.property('retweeted_status.id', 'deadbeef') }) }) }) @@ -284,9 +284,9 @@ describe('API Entities normalizer', () => { }) expect(parseNotification(notif)).to.have.property('id', 123) expect(parseNotification(notif)).to.have.property('seen', false) - expect(parseNotification(notif)).to.have.deep.property('status.id', '444') - expect(parseNotification(notif)).to.have.deep.property('action.id', '444') - expect(parseNotification(notif)).to.have.deep.property('from_profile.id', 'spurdo') + expect(parseNotification(notif)).to.have.nested.property('status.id', '444') + expect(parseNotification(notif)).to.have.nested.property('action.id', '444') + expect(parseNotification(notif)).to.have.nested.property('from_profile.id', 'spurdo') }) it('correctly normalizes favorite notifications', () => { @@ -303,9 +303,9 @@ describe('API Entities normalizer', () => { expect(parseNotification(notif)).to.have.property('id', 123) expect(parseNotification(notif)).to.have.property('type', 'like') expect(parseNotification(notif)).to.have.property('seen', true) - expect(parseNotification(notif)).to.have.deep.property('status.id', '4412') - expect(parseNotification(notif)).to.have.deep.property('action.id', '444') - expect(parseNotification(notif)).to.have.deep.property('from_profile.id', 'spurdo') + expect(parseNotification(notif)).to.have.nested.property('status.id', '4412') + expect(parseNotification(notif)).to.have.nested.property('action.id', '444') + expect(parseNotification(notif)).to.have.nested.property('from_profile.id', 'spurdo') }) }) -- cgit v1.2.3-70-g09d2 From 0123872b56ccd2d534913706ae0f27ea8d6481de Mon Sep 17 00:00:00 2001 From: Henry Jameson Date: Fri, 12 Aug 2022 00:50:08 +0300 Subject: fixes + fixes for anon users --- src/components/mobile_nav/mobile_nav.vue | 2 +- src/components/nav_panel/nav_panel.js | 13 +++++++++++++ src/components/nav_panel/nav_panel.vue | 2 +- src/components/navigation/navigation.js | 1 - src/components/navigation/navigation_entry.vue | 2 +- src/components/navigation/navigation_pins.js | 7 +++++++ src/components/navigation/navigation_pins.vue | 1 + test/unit/specs/modules/serverSideStorage.spec.js | 4 ++-- 8 files changed, 26 insertions(+), 6 deletions(-) (limited to 'test/unit/specs') diff --git a/src/components/mobile_nav/mobile_nav.vue b/src/components/mobile_nav/mobile_nav.vue index 82e726a4..01a77589 100644 --- a/src/components/mobile_nav/mobile_nav.vue +++ b/src/components/mobile_nav/mobile_nav.vue @@ -86,6 +86,7 @@ grid-template-columns: 2fr auto; width: 100%; box-sizing: border-box; + a { color: var(--topBarLink, $fallback--link); } @@ -175,7 +176,6 @@ .pinned-item { flex-grow: 1; - text-align: center; } } diff --git a/src/components/nav_panel/nav_panel.js b/src/components/nav_panel/nav_panel.js index 7daa5e6a..b4e1ec0b 100644 --- a/src/components/nav_panel/nav_panel.js +++ b/src/components/nav_panel/nav_panel.js @@ -86,6 +86,19 @@ const NavPanel = { 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.private, + currentUser: this.currentUser + } + ) + }, rootItems () { return filterNavigation( Object diff --git a/src/components/nav_panel/nav_panel.vue b/src/components/nav_panel/nav_panel.vue index 6e89094a..84b000a4 100644 --- a/src/components/nav_panel/nav_panel.vue +++ b/src/components/nav_panel/nav_panel.vue @@ -37,7 +37,7 @@ class="timelines-background" >
      - +
  • diff --git a/src/components/navigation/navigation.js b/src/components/navigation/navigation.js index f37d37fe..57db2253 100644 --- a/src/components/navigation/navigation.js +++ b/src/components/navigation/navigation.js @@ -1,7 +1,6 @@ export const TIMELINES = { home: { route: 'friends', - anonRoute: 'public-timeline', icon: 'home', label: 'nav.home_timeline', criteria: ['!private'] diff --git a/src/components/navigation/navigation_entry.vue b/src/components/navigation/navigation_entry.vue index 7d761395..0dcf5d85 100644 --- a/src/components/navigation/navigation_entry.vue +++ b/src/components/navigation/navigation_entry.vue @@ -21,7 +21,7 @@ @click.stop.prevent="togglePin(item.name)" > 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 diff --git a/src/components/navigation/navigation_pins.vue b/src/components/navigation/navigation_pins.vue index 754aad7a..f421b2be 100644 --- a/src/components/navigation/navigation_pins.vue +++ b/src/components/navigation/navigation_pins.vue @@ -46,6 +46,7 @@ position: relative; flex: 0 0 3em; min-width: 2em; + text-align: center; & .svg-inline--fa, & .iconLetter { diff --git a/test/unit/specs/modules/serverSideStorage.spec.js b/test/unit/specs/modules/serverSideStorage.spec.js index ada3b7ca..edb23e8a 100644 --- a/test/unit/specs/modules/serverSideStorage.spec.js +++ b/test/unit/specs/modules/serverSideStorage.spec.js @@ -116,7 +116,7 @@ describe('The serverSideStorage module', () => { expect(state.prefsStorage._journal.length).to.eql(1) expect(state.prefsStorage._journal[0]).to.eql({ path: 'simple.testing', - command: 'set', + operation: 'set', args: [1], // should have A timestamp, we don't really care what it is timestamp: state.prefsStorage._journal[0].timestamp @@ -132,7 +132,7 @@ describe('The serverSideStorage module', () => { expect(state.prefsStorage._journal.length).to.eql(1) expect(state.prefsStorage._journal[0]).to.eql({ path: 'simple.testing', - command: 'set', + operation: 'set', args: [2], // should have A timestamp, we don't really care what it is timestamp: state.prefsStorage._journal[0].timestamp -- cgit v1.2.3-70-g09d2 From 8d3d8fffab0106a8aff5822044a8c3c30bd6e057 Mon Sep 17 00:00:00 2001 From: Henry Jameson Date: Fri, 12 Aug 2022 01:19:19 +0300 Subject: fixes, clear cache on logout --- src/components/nav_panel/nav_panel.js | 4 ++-- src/components/navigation/navigation_entry.js | 1 + src/components/navigation/navigation_pins.js | 2 +- src/modules/serverSideStorage.js | 17 +++++++++++++---- src/modules/users.js | 1 + .../entity_normalizer/entity_normalizer.service.js | 1 + test/unit/specs/modules/serverSideStorage.spec.js | 2 +- 7 files changed, 20 insertions(+), 8 deletions(-) (limited to 'test/unit/specs') diff --git a/src/components/nav_panel/nav_panel.js b/src/components/nav_panel/nav_panel.js index 0d71a924..26e8440a 100644 --- a/src/components/nav_panel/nav_panel.js +++ b/src/components/nav_panel/nav_panel.js @@ -95,7 +95,7 @@ const NavPanel = { { hasChats: this.pleromaChatMessagesAvailable, isFederating: this.federating, - isPrivate: this.private, + isPrivate: this.privateMode, currentUser: this.currentUser } ) @@ -108,7 +108,7 @@ const NavPanel = { { hasChats: this.pleromaChatMessagesAvailable, isFederating: this.federating, - isPrivate: this.private, + isPrivate: this.privateMode, currentUser: this.currentUser } ) diff --git a/src/components/navigation/navigation_entry.js b/src/components/navigation/navigation_entry.js index 09c216ed..e17e9436 100644 --- a/src/components/navigation/navigation_entry.js +++ b/src/components/navigation/navigation_entry.js @@ -16,6 +16,7 @@ const NavigationEntry = { } else { this.$store.commit('addCollectionPreference', { path: 'collections.pinnedNavItems', value }) } + this.$store.dispatch('pushServerSideStorage') } }, computed: { diff --git a/src/components/navigation/navigation_pins.js b/src/components/navigation/navigation_pins.js index 8a892466..43be4275 100644 --- a/src/components/navigation/navigation_pins.js +++ b/src/components/navigation/navigation_pins.js @@ -64,7 +64,7 @@ const NavPanel = { { hasChats: this.pleromaChatMessagesAvailable, isFederating: this.federating, - isPrivate: this.private, + isPrivate: this.privateMode, currentUser: this.currentUser } ) diff --git a/src/modules/serverSideStorage.js b/src/modules/serverSideStorage.js index d95fbb8a..5581783f 100644 --- a/src/modules/serverSideStorage.js +++ b/src/modules/serverSideStorage.js @@ -51,8 +51,9 @@ export const _moveItemInArray = (array, value, movement) => { return newArray } -const _wrapData = (data) => ({ +const _wrapData = (data, userName) => ({ ...data, + _user: userName, _timestamp: Date.now(), _version: VERSION }) @@ -254,10 +255,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._user !== userData.fqn) { + console.warn('cache belongs to another user! reinitializing local cache!') + cache = null + } cache = _doMigrations(cache) @@ -371,12 +379,12 @@ export const mutations = { ] state.dirty = true }, - updateCache (state) { + updateCache (state, { username }) { state.prefsStorage._journal = _mergeJournal(state.prefsStorage._journal) state.cache = _wrapData({ flagStorage: toRaw(state.flagStorage), prefsStorage: toRaw(state.prefsStorage) - }) + }, username) } } @@ -388,8 +396,9 @@ const serverSideStorage = { actions: { pushServerSideStorage ({ state, rootState, commit }, { force = false } = {}) { const needPush = state.dirty || force + console.log(needPush) if (!needPush) return - commit('updateCache') + commit('updateCache', { username: rootState.users.currentUser.fqn }) const params = { pleroma_settings_store: { 'pleroma-fe': state.cache } } rootState.api.backendInteractor .updateProfile({ params }) diff --git a/src/modules/users.js b/src/modules/users.js index b6fb9746..fe92d697 100644 --- a/src/modules/users.js +++ b/src/modules/users.js @@ -509,6 +509,7 @@ const users = { store.dispatch('setLastTimeline', 'public-timeline') store.dispatch('setLayoutWidth', windowWidth()) store.dispatch('setLayoutHeight', windowHeight()) + store.commit('clearServerSideStorage') }) }, loginUser (store, accessToken) { diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js index e9cbcfe6..b1ad2691 100644 --- a/src/services/entity_normalizer/entity_normalizer.service.js +++ b/src/services/entity_normalizer/entity_normalizer.service.js @@ -48,6 +48,7 @@ export const parseUser = (data) => { if (masto) { output.screen_name = data.acct + output.fqn = data.fqn output.statusnet_profile_url = data.url // There's nothing else to get diff --git a/test/unit/specs/modules/serverSideStorage.spec.js b/test/unit/specs/modules/serverSideStorage.spec.js index edb23e8a..f10e21e6 100644 --- a/test/unit/specs/modules/serverSideStorage.spec.js +++ b/test/unit/specs/modules/serverSideStorage.spec.js @@ -127,7 +127,7 @@ describe('The serverSideStorage module', () => { const state = cloneDeep(defaultState) setPreference(state, { path: 'simple.testing', value: 1 }) setPreference(state, { path: 'simple.testing', value: 2 }) - updateCache(state) + updateCache(state, { username: 'test' }) expect(state.prefsStorage.simple.testing).to.eql(2) expect(state.prefsStorage._journal.length).to.eql(1) expect(state.prefsStorage._journal[0]).to.eql({ -- cgit v1.2.3-70-g09d2 From 840ce063971d68063d380fc5f3aacc0564363b50 Mon Sep 17 00:00:00 2001 From: Henry Jameson Date: Tue, 16 Aug 2022 19:24:20 +0300 Subject: proper journal trimming + remove some old workaround to my local bad data --- src/modules/serverSideStorage.js | 46 +++++++++++++---------- test/unit/specs/modules/serverSideStorage.spec.js | 15 +++++++- 2 files changed, 39 insertions(+), 22 deletions(-) (limited to 'test/unit/specs') diff --git a/src/modules/serverSideStorage.js b/src/modules/serverSideStorage.js index eb089be6..08b11b0b 100644 --- a/src/modules/serverSideStorage.js +++ b/src/modules/serverSideStorage.js @@ -1,5 +1,5 @@ import { toRaw } from 'vue' -import { isEqual, uniqWith, cloneDeep, set, get, clamp } 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 @@ -131,25 +131,32 @@ export const _mergeFlags = (recent, stale, allFlagKeys) => { })) } -const _mergeJournal = (a, b) => uniqWith( - // TODO use groupBy to group by path, then trim them depending on operations, - // i.e. if field got removed in the end - no need to sort it beforehand, if field - // got re-added no need to remove it and add it etc. - [ - ...(Array.isArray(a) ? a : []), - ...(Array.isArray(b) ? b : []) - ].sort((a, b) => a.timestamp > b.timestamp ? -1 : 1), - (a, b) => { - if (a.operation !== b.operation) return false - switch (a.operation) { - case 'set': - case 'arrangeSet': - return a.path === b.path - default: - return a.path === b.path && a.timestamp === b.timestamp +const _mergeJournal = (...journals) => { + const allJournals = flatten(journals.map(j => Array.isArray(j) ? j : [])) + 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 } - } -).reverse() + }) + return flatten(trimmedGrouped) + .sort((a, b) => a.timestamp > b.timestamp ? 1 : -1) +} export const _mergePrefs = (recent, stale, allFlagKeys) => { if (!stale) return recent @@ -169,7 +176,6 @@ export const _mergePrefs = (recent, stale, allFlagKeys) => { const resultOutput = { ...recentData } const totalJournal = _mergeJournal(staleJournal, recentJournal) totalJournal.forEach(({ path, timestamp, operation, command, args }) => { - operation = operation || command if (path.startsWith('_')) { console.error(`journal contains entry to edit internal (starts with _) field '${path}', something is incorrect here, ignoring.`) return diff --git a/test/unit/specs/modules/serverSideStorage.spec.js b/test/unit/specs/modules/serverSideStorage.spec.js index f10e21e6..7adce20d 100644 --- a/test/unit/specs/modules/serverSideStorage.spec.js +++ b/test/unit/specs/modules/serverSideStorage.spec.js @@ -107,7 +107,7 @@ describe('The serverSideStorage module', () => { }) }) describe('setPreference', () => { - const { setPreference, updateCache } = mutations + const { setPreference, updateCache, addToCollection, removeFromCollection } = mutations it('should set preference and update journal log accordingly', () => { const state = cloneDeep(defaultState) @@ -123,12 +123,15 @@ describe('The serverSideStorage module', () => { }) }) - it('should keep journal to a minimum (one entry per path for sets)', () => { + 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 }) + addToCollection(state, { path: 'collections.testing', value: 2 }) + removeFromCollection(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(1) expect(state.prefsStorage._journal[0]).to.eql({ path: 'simple.testing', @@ -137,6 +140,14 @@ describe('The serverSideStorage module', () => { // 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: 'collection.testing', + operation: 'remove', + args: [2], + // should have A timestamp, we don't really care what it is + timestamp: state.prefsStorage._journal[1].timestamp + }) + }) }) }) }) -- cgit v1.2.3-70-g09d2 From 821a09109c4747fcec49059f03c8cc908bd07ac5 Mon Sep 17 00:00:00 2001 From: Henry Jameson Date: Tue, 16 Aug 2022 20:00:29 +0300 Subject: fix list tests --- src/modules/lists.js | 5 +++-- test/unit/specs/modules/lists.spec.js | 16 ++++++++-------- test/unit/specs/modules/serverSideStorage.spec.js | 1 - 3 files changed, 11 insertions(+), 11 deletions(-) (limited to 'test/unit/specs') diff --git a/src/modules/lists.js b/src/modules/lists.js index d9fab969..22fed800 100644 --- a/src/modules/lists.js +++ b/src/modules/lists.js @@ -15,10 +15,11 @@ export const mutations = { } state.allListsObject[listId].title = title - if (!find(state.allLists, { id: listId })) { + const entry = find(state.allLists, { id: listId }) + if (!entry) { state.allLists.push({ id: listId, title }) } else { - find(state.allLists, { id: listId }).title = title + entry.title = title } }, setListAccounts (state, { listId, accountIds }) { diff --git a/test/unit/specs/modules/lists.spec.js b/test/unit/specs/modules/lists.spec.js index ac9af1b6..e43106ea 100644 --- a/test/unit/specs/modules/lists.spec.js +++ b/test/unit/specs/modules/lists.spec.js @@ -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({}) }) diff --git a/test/unit/specs/modules/serverSideStorage.spec.js b/test/unit/specs/modules/serverSideStorage.spec.js index 7adce20d..69cad0a5 100644 --- a/test/unit/specs/modules/serverSideStorage.spec.js +++ b/test/unit/specs/modules/serverSideStorage.spec.js @@ -148,7 +148,6 @@ describe('The serverSideStorage module', () => { timestamp: state.prefsStorage._journal[1].timestamp }) }) - }) }) }) -- cgit v1.2.3-70-g09d2 From 38bd59ceb0182de15e2e97d750df59ad53dfa51a Mon Sep 17 00:00:00 2001 From: Henry Jameson Date: Tue, 16 Aug 2022 20:14:18 +0300 Subject: fix journal test --- test/unit/specs/modules/serverSideStorage.spec.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) (limited to 'test/unit/specs') diff --git a/test/unit/specs/modules/serverSideStorage.spec.js b/test/unit/specs/modules/serverSideStorage.spec.js index 69cad0a5..be249eed 100644 --- a/test/unit/specs/modules/serverSideStorage.spec.js +++ b/test/unit/specs/modules/serverSideStorage.spec.js @@ -107,7 +107,7 @@ describe('The serverSideStorage module', () => { }) }) describe('setPreference', () => { - const { setPreference, updateCache, addToCollection, removeFromCollection } = mutations + const { setPreference, updateCache, addCollectionPreference, removeCollectionPreference } = mutations it('should set preference and update journal log accordingly', () => { const state = cloneDeep(defaultState) @@ -127,12 +127,12 @@ describe('The serverSideStorage module', () => { const state = cloneDeep(defaultState) setPreference(state, { path: 'simple.testing', value: 1 }) setPreference(state, { path: 'simple.testing', value: 2 }) - addToCollection(state, { path: 'collections.testing', value: 2 }) - removeFromCollection(state, { path: 'collections.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(1) + expect(state.prefsStorage._journal.length).to.eql(2) expect(state.prefsStorage._journal[0]).to.eql({ path: 'simple.testing', operation: 'set', @@ -141,8 +141,8 @@ describe('The serverSideStorage module', () => { timestamp: state.prefsStorage._journal[0].timestamp }) expect(state.prefsStorage._journal[1]).to.eql({ - path: 'collection.testing', - operation: 'remove', + 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 -- cgit v1.2.3-70-g09d2 From 0a79a747730bb4a10eb2544412dab68a10602240 Mon Sep 17 00:00:00 2001 From: Tusooa Zhu Date: Mon, 29 Aug 2022 18:46:41 -0400 Subject: Use dedicated indicator for non-ascii domain names --- src/components/basic_user_card/basic_user_card.js | 4 ++- src/components/basic_user_card/basic_user_card.vue | 8 ++--- src/components/emoji_input/emoji_input.js | 4 ++- src/components/emoji_input/emoji_input.vue | 16 ++++++++- src/components/emoji_input/suggestor.js | 11 ++++--- src/components/mention_link/mention_link.js | 2 ++ src/components/mention_link/mention_link.vue | 3 ++ src/components/notification/notification.js | 4 ++- src/components/notification/notification.vue | 21 ++++++------ src/components/status/status.js | 4 ++- src/components/status/status.vue | 16 ++++----- .../unicode_domain_indicator.vue | 26 +++++++++++++++ src/components/user_card/user_card.js | 4 ++- src/components/user_card/user_card.vue | 9 ++--- src/components/user_link/user_link.vue | 38 ++++++++++++++++++++++ .../user_list_popover/user_list_popover.js | 2 ++ .../user_list_popover/user_list_popover.vue | 2 +- .../user_reporting_modal/user_reporting_modal.js | 4 ++- .../user_reporting_modal/user_reporting_modal.vue | 10 ++++-- src/i18n/en.json | 3 ++ .../entity_normalizer/entity_normalizer.service.js | 6 ++-- .../entity_normalizer/entity_normalizer.spec.js | 3 +- 22 files changed, 151 insertions(+), 49 deletions(-) create mode 100644 src/components/unicode_domain_indicator/unicode_domain_indicator.vue create mode 100644 src/components/user_link/user_link.vue (limited to 'test/unit/specs') diff --git a/src/components/basic_user_card/basic_user_card.js b/src/components/basic_user_card/basic_user_card.js index 8b1a2c38..31de2d75 100644 --- a/src/components/basic_user_card/basic_user_card.js +++ b/src/components/basic_user_card/basic_user_card.js @@ -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) { diff --git a/src/components/basic_user_card/basic_user_card.vue b/src/components/basic_user_card/basic_user_card.vue index 9cca7840..418de926 100644 --- a/src/components/basic_user_card/basic_user_card.vue +++ b/src/components/basic_user_card/basic_user_card.vue @@ -30,12 +30,10 @@ />
    - - @{{ user.screen_name_ui }} - + :user="user" + />
    diff --git a/src/components/emoji_input/emoji_input.js b/src/components/emoji_input/emoji_input.js index 5ba3907f..b664d6b3 100644 --- a/src/components/emoji_input/emoji_input.js +++ b/src/components/emoji_input/emoji_input.js @@ -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 () { diff --git a/src/components/emoji_input/emoji_input.vue b/src/components/emoji_input/emoji_input.vue index 7d95ab7e..81b81913 100644 --- a/src/components/emoji_input/emoji_input.vue +++ b/src/components/emoji_input/emoji_input.vue @@ -50,7 +50,21 @@ {{ suggestion.replacement }}
    - {{ suggestion.displayText }} + + {{ suggestion.displayText }} + + + {{ suggestion.displayText }} + {{ suggestion.detailText }}
    diff --git a/src/components/emoji_input/suggestor.js b/src/components/emoji_input/suggestor.js index e8efbd1e..0ddb4d68 100644 --- a/src/components/emoji_input/suggestor.js +++ b/src/components/emoji_input/suggestor.js @@ -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 */ diff --git a/src/components/mention_link/mention_link.js b/src/components/mention_link/mention_link.js index 4a74fbe2..6515bd11 100644 --- a/src/components/mention_link/mention_link.js +++ b/src/components/mention_link/mention_link.js @@ -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: { diff --git a/src/components/mention_link/mention_link.vue b/src/components/mention_link/mention_link.vue index 3af502ef..869a3257 100644 --- a/src/components/mention_link/mention_link.vue +++ b/src/components/mention_link/mention_link.vue @@ -47,6 +47,9 @@ class="serverName" :class="{ '-faded': shouldFadeDomain }" v-html="'@' + serverName" + /> - - {{ notification.from_profile.screen_name_ui }} - +
    - - @{{ user.screen_name_ui }} - + :user="user" + /> + + diff --git a/src/components/user_list_popover/user_list_popover.js b/src/components/user_list_popover/user_list_popover.js index e24eb9f7..046e0abd 100644 --- a/src/components/user_list_popover/user_list_popover.js +++ b/src/components/user_list_popover/user_list_popover.js @@ -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')) }, diff --git a/src/components/user_list_popover/user_list_popover.vue b/src/components/user_list_popover/user_list_popover.vue index a3ce54c3..635dc7f6 100644 --- a/src/components/user_list_popover/user_list_popover.vue +++ b/src/components/user_list_popover/user_list_popover.vue @@ -29,7 +29,7 @@ :emoji="user.emoji" /> - {{ user.screen_name_ui }} + {{ user.screen_name_ui }}
    diff --git a/src/components/user_reporting_modal/user_reporting_modal.js b/src/components/user_reporting_modal/user_reporting_modal.js index 85ffc661..67fde084 100644 --- a/src/components/user_reporting_modal/user_reporting_modal.js +++ b/src/components/user_reporting_modal/user_reporting_modal.js @@ -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 { diff --git a/src/components/user_reporting_modal/user_reporting_modal.vue b/src/components/user_reporting_modal/user_reporting_modal.vue index 429a66e2..8c42ab7b 100644 --- a/src/components/user_reporting_modal/user_reporting_modal.vue +++ b/src/components/user_reporting_modal/user_reporting_modal.vue @@ -5,9 +5,13 @@ >
    -
    - {{ $t('user_reporting.title', [user.screen_name_ui]) }} -
    + + +
    diff --git a/src/i18n/en.json b/src/i18n/en.json index 91722d9a..4ce56678 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -1006,5 +1006,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." } } diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js index e9cbcfe6..ce316832 100644 --- a/src/services/entity_normalizer/entity_normalizer.service.js +++ b/src/services/entity_normalizer/entity_normalizer.service.js @@ -214,12 +214,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 } } diff --git a/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js b/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js index 98bb05a8..3923596b 100644 --- a/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js +++ b/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js @@ -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) }) }) -- cgit v1.2.3-70-g09d2 From 15124319735f3bf0cb384edb95a0060f902ccc63 Mon Sep 17 00:00:00 2001 From: Henry Jameson Date: Thu, 24 Nov 2022 22:31:38 +0200 Subject: fix leaky journal by running uniq on addToCollection entries --- src/modules/serverSideStorage.js | 15 ++++++++++++--- test/unit/specs/modules/serverSideStorage.spec.js | 12 ++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) (limited to 'test/unit/specs') diff --git a/src/modules/serverSideStorage.js b/src/modules/serverSideStorage.js index 56164be7..c933ce8d 100644 --- a/src/modules/serverSideStorage.js +++ b/src/modules/serverSideStorage.js @@ -1,5 +1,5 @@ import { toRaw } from 'vue' -import { isEqual, cloneDeep, set, get, clamp, flatten, groupBy, findLastIndex, takeRight } from 'lodash' +import { isEqual, cloneDeep, set, get, clamp, flatten, groupBy, findLastIndex, takeRight, uniqWith } from 'lodash' import { CURRENT_UPDATE_COUNTER } from 'src/components/update_notification/update_notification.js' export const VERSION = 1 @@ -149,12 +149,21 @@ const _mergeJournal = (...journals) => { if (path.startsWith('collections')) { const lastRemoveIndex = findLastIndex(journal, ({ operation }) => operation === 'removeFromCollection') // everything before last remove is unimportant + let remainder if (lastRemoveIndex > 0) { - return journal.slice(lastRemoveIndex) + remainder = journal.slice(lastRemoveIndex) } else { // everything else doesn't need trimming - return journal + remainder = journal } + return uniqWith(remainder, (a, b) => { + if (a.path !== b.path) { return false } + if (a.operation !== b.operation) { return false } + if (a.operation === 'addToCollection') { + return a.args[0] === b.args[0] + } + return false + }) } else if (path.startsWith('simple')) { // Only the last record is important return takeRight(journal) diff --git a/test/unit/specs/modules/serverSideStorage.spec.js b/test/unit/specs/modules/serverSideStorage.spec.js index be249eed..2e43263a 100644 --- a/test/unit/specs/modules/serverSideStorage.spec.js +++ b/test/unit/specs/modules/serverSideStorage.spec.js @@ -148,6 +148,18 @@ describe('The serverSideStorage module', () => { timestamp: state.prefsStorage._journal[1].timestamp }) }) + + it('should remove duplicate entries from journal', () => { + const state = cloneDeep(defaultState) + setPreference(state, { path: 'simple.testing', value: 1 }) + setPreference(state, { path: 'simple.testing', value: 1 }) + addCollectionPreference(state, { path: 'collections.testing', value: 2 }) + addCollectionPreference(state, { path: 'collections.testing', value: 2 }) + updateCache(state, { username: 'test' }) + expect(state.prefsStorage.simple.testing).to.eql(1) + expect(state.prefsStorage.collections.testing).to.eql([2]) + expect(state.prefsStorage._journal.length).to.eql(2) + }) }) }) -- cgit v1.2.3-70-g09d2 From 74813864fcbd513a5782b739055f132c68e6eca7 Mon Sep 17 00:00:00 2001 From: Henry Jameson Date: Sun, 27 Nov 2022 00:11:54 +0200 Subject: fix tests --- src/components/rich_content/rich_content.jsx | 12 +++++++----- src/services/html_converter/utility.service.js | 9 ++++++++- test/unit/specs/components/rich_content.spec.js | 21 +++++++++++++++++---- 3 files changed, 32 insertions(+), 10 deletions(-) (limited to 'test/unit/specs') diff --git a/src/components/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx index 8a5758af..7881e365 100644 --- a/src/components/rich_content/rich_content.jsx +++ b/src/components/rich_content/rich_content.jsx @@ -150,6 +150,7 @@ export default { if (Array.isArray(item)) { const [opener, children, closer] = item const Tag = getTagName(opener) + const fullAttrs = getAttrs(opener, () => true) const attrs = getAttrs(opener) const previouslyMentions = currentMentions !== null /* During grouping of mentions we trim all the empty text elements @@ -171,7 +172,7 @@ export default { return ['', [mentionsLinePadding, renderImage(opener)], ''] case 'a': // replace mentions with MentionLink if (!this.handleLinks) break - if (attrs['class'] && attrs['class'].includes('mention')) { + if (fullAttrs.class && fullAttrs.class.includes('mention')) { // Handling mentions here return renderMention(attrs, children) } else { @@ -179,7 +180,7 @@ export default { break } case 'span': - if (this.handleLinks && attrs.class && attrs.class.includes('h-card')) { + if (this.handleLinks && fullAttrs.class && fullAttrs.class.includes('h-card')) { return ['', children.map(processItem), ''] } } @@ -215,11 +216,12 @@ export default { switch (Tag) { case 'a': { // replace mentions with MentionLink if (!this.handleLinks) break - const attrs = getAttrs(opener) + const fullAttrs = getAttrs(opener, () => true) + const attrs = getAttrs(opener, () => true) // should only be this if ( - (attrs.class && attrs.class.includes('hashtag')) || // Pleroma style - (attrs.rel === 'tag') // Mastodon style + (fullAttrs.class && fullAttrs.class.includes('hashtag')) || // Pleroma style + (fullAttrs.rel === 'tag') // Mastodon style ) { return renderHashtag(attrs, children, encounteredTextReverse) } else { diff --git a/src/services/html_converter/utility.service.js b/src/services/html_converter/utility.service.js index c8670cb4..f1042971 100644 --- a/src/services/html_converter/utility.service.js +++ b/src/services/html_converter/utility.service.js @@ -28,7 +28,14 @@ export const getAttrs = (tag, filter) => { if (!v) return [k, true] return [k, v.substring(1, v.length - 1)] }) - const defaultFilter = ([k, v]) => (k.toLowerCase() !== 'class' && k.toLowerCase() !== 'style') + const defaultFilter = ([k, v]) => { + const attrKey = k.toLowerCase() + if (attrKey === 'style') return false + if (attrKey === 'class') { + return v === 'greentext' || v === 'cyantext' + } + return true + } return Object.fromEntries(attrs.filter(filter || defaultFilter)) } diff --git a/test/unit/specs/components/rich_content.spec.js b/test/unit/specs/components/rich_content.spec.js index 616df6a0..427eb5ed 100644 --- a/test/unit/specs/components/rich_content.spec.js +++ b/test/unit/specs/components/rich_content.spec.js @@ -19,9 +19,11 @@ const global = { } } -const makeMention = (who) => { +const makeMention = (who, noClass) => { attentions.push({ statusnet_profile_url: `https://fake.tld/@${who}` }) - return `@${who}` + return noClass + ? `@${who}` + : `@${who}` } const p = (...data) => `

    ${data.join('')}

    ` const compwrap = (...data) => `${data.join('')}` @@ -142,6 +144,17 @@ describe('RichContent', () => { makeMention('Josh'), makeMention('Jeremy') ].join('') ].join('\n') + const strippedHtml = [ + [ + makeMention('Jack', true), + 'let\'s meet up with ', + makeMention('Janet', true) + ].join(''), + [ + makeMention('John', true), + makeMention('Josh', true), makeMention('Jeremy', true) + ].join('') + ].join('\n') const wrapper = shallowMount(RichContent, { global, @@ -154,7 +167,7 @@ describe('RichContent', () => { } }) - expect(wrapper.html()).to.eql(compwrap(html)) + expect(wrapper.html()).to.eql(compwrap(strippedHtml)) }) it('Adds greentext and cyantext to the post', () => { @@ -412,7 +425,7 @@ describe('RichContent', () => { 'Testing' ].join('') const expected = [ - '', + '', '', '', '', -- cgit v1.2.3-70-g09d2