diff --git a/spec/renderer/integration/store/TimelineSpace.spec.js b/spec/renderer/integration/store/TimelineSpace.spec.js index 1180614d..9f3e0898 100644 --- a/spec/renderer/integration/store/TimelineSpace.spec.js +++ b/spec/renderer/integration/store/TimelineSpace.spec.js @@ -63,6 +63,13 @@ const PublicStore = { } } +const MentionStore = { + namespaced: true, + actions: { + fetchMentions: jest.fn() + } +} + const contentsStore = { namespaced: true, modules: { @@ -70,7 +77,8 @@ const contentsStore = { Notifications: notificationStore, DirectMessages: DMStore, Local: LocalStore, - Public: PublicStore + Public: PublicStore, + Mentions: MentionStore } } diff --git a/spec/renderer/integration/store/TimelineSpace/Contents/Home.spec.js b/spec/renderer/integration/store/TimelineSpace/Contents/Home.spec.js index adf9c6c7..59daba6f 100644 --- a/spec/renderer/integration/store/TimelineSpace/Contents/Home.spec.js +++ b/spec/renderer/integration/store/TimelineSpace/Contents/Home.spec.js @@ -6,7 +6,7 @@ import Home from '~/src/renderer/store/TimelineSpace/Contents/Home' jest.genMockFromModule('megalodon') jest.mock('megalodon') -const state = () => { +let state = () => { return { lazyLoading: false, heading: true, @@ -89,6 +89,22 @@ describe('Home', () => { }) }) describe('success', () => { + beforeAll(() => { + state = () => { + return { + lazyLoading: false, + heading: true, + timeline: [ + { id: 3 }, + { id: 4 } + ], + unreadTimeline: [], + filter: '', + showReblogs: true, + showReplies: true + } + } + }) it('should be updated', async () => { const mockClient = { get: () => { @@ -106,6 +122,8 @@ describe('Home', () => { await store.dispatch('Home/lazyFetchTimeline', { id: 20 }) expect(store.state.Home.lazyLoading).toEqual(false) expect(store.state.Home.timeline).toEqual([ + { id: 3 }, + { id: 4 }, { id: 19 }, { id: 18 } ]) diff --git a/spec/renderer/integration/store/TimelineSpace/Contents/Mentions.spec.js b/spec/renderer/integration/store/TimelineSpace/Contents/Mentions.spec.js new file mode 100644 index 00000000..843561d2 --- /dev/null +++ b/spec/renderer/integration/store/TimelineSpace/Contents/Mentions.spec.js @@ -0,0 +1,178 @@ +import Mastodon from 'megalodon' +import { createLocalVue } from '@vue/test-utils' +import Vuex from 'vuex' +import Mentions from '~/src/renderer/store/TimelineSpace/Contents/Mentions' + +jest.genMockFromModule('megalodon') +jest.mock('megalodon') + +let state = () => { + return { + lazyLoading: false, + heading: true, + mentions: [], + unreadMentions: [], + filter: '' + } +} + +const initStore = () => { + return { + namespaced: true, + state: state(), + actions: Mentions.actions, + mutations: Mentions.mutations, + getters: Mentions.getters + } +} +const timelineState = { + namespaced: true, + state: { + account: { + accessToken: 'token', + baseURL: 'http://localhost' + } + } +} + +describe('Mentions', () => { + let store + let localVue + + beforeEach(() => { + localVue = createLocalVue() + localVue.use(Vuex) + store = new Vuex.Store({ + modules: { + Mentions: initStore(), + TimelineSpace: timelineState + } + }) + Mastodon.mockClear() + }) + + describe('fetchMentions', () => { + it('should be updated', async () => { + const mockClient = { + get: () => { + return new Promise((resolve, reject) => { + resolve({ + data: [ + { id: 1, type: 'mention' }, + { id: 2, type: 'favourite' }, + { id: 3, type: 'reblog' }, + { id: 4, type: 'follow' } + ] + }) + }) + } + } + + Mastodon.mockImplementation(() => mockClient) + await store.dispatch('Mentions/fetchMentions') + expect(store.state.Mentions.mentions).toEqual([ + { id: 1, type: 'mention' }, + { id: 2, type: 'favourite' }, + { id: 3, type: 'reblog' }, + { id: 4, type: 'follow' } + ]) + }) + }) + + describe('lazyFetchMentions', () => { + describe('last is null', () => { + it('should not be updated', async () => { + const result = await store.dispatch('Mentions/lazyFetchMentions', null) + expect(result).toEqual(null) + }) + }) + + describe('loading', () => { + beforeAll(() => { + state = () => { + return { + lazyLoading: true, + heading: true, + mentions: [], + unreadMentions: [], + filter: '' + } + } + }) + it('should not be updated', async () => { + const result = await store.dispatch('Mentions/lazyFetchMentions', {}) + expect(result).toEqual(null) + }) + }) + + describe('success', () => { + beforeAll(() => { + state = () => { + return { + lazyLoading: false, + heading: true, + mentions: [ + { id: 1, type: 'mention' }, + { id: 2, type: 'favourite' }, + { id: 3, type: 'reblog' }, + { id: 4, type: 'follow' } + ], + unreadMentions: [], + filter: '' + } + } + }) + it('should be updated', async () => { + const mockClient = { + get: () => { + return new Promise((resolve, reject) => { + resolve({ + data: [ + { id: 5, type: 'mention' }, + { id: 6, type: 'favourite' } + ] + }) + }) + } + } + + Mastodon.mockImplementation(() => mockClient) + await store.dispatch('Mentions/lazyFetchMentions', { id: 1 }) + expect(store.state.Mentions.mentions).toEqual([ + { id: 1, type: 'mention' }, + { id: 2, type: 'favourite' }, + { id: 3, type: 'reblog' }, + { id: 4, type: 'follow' }, + { id: 5, type: 'mention' }, + { id: 6, type: 'favourite' } + ]) + expect(store.state.Mentions.lazyLoading).toEqual(false) + }) + }) + }) + + describe('mentions', () => { + beforeAll(() => { + state = () => { + return { + lazyLoading: false, + heading: true, + mentions: [ + { id: 1, type: 'mention' }, + { id: 2, type: 'favourite' }, + { id: 3, type: 'reblog' }, + { id: 4, type: 'follow' } + ], + unreadMentions: [], + filter: '' + } + } + }) + it('should return only mentions', () => { + const mentions = store.getters['Mentions/mentions'] + expect(mentions).toEqual([ + { id: 1, type: 'mention' } + ]) + }) + }) +}) diff --git a/spec/renderer/unit/store/TimelineSpace/Contents/Mentions.spec.js b/spec/renderer/unit/store/TimelineSpace/Contents/Mentions.spec.js new file mode 100644 index 00000000..69ad0e56 --- /dev/null +++ b/spec/renderer/unit/store/TimelineSpace/Contents/Mentions.spec.js @@ -0,0 +1,114 @@ +import Mentions from '@/store/TimelineSpace/Contents/Mentions' + +describe('TimelineSpace/Contents/Mentions', () => { + describe('mutations', () => { + let state + beforeEach(() => { + state = { + lazyLoading: false, + heading: true, + mentions: [], + unreadMentions: [], + filter: '' + } + }) + + describe('appendMentions', () => { + describe('heading', () => { + beforeEach(() => { + state = { + lazyLoading: false, + heading: true, + mentions: [5, 4, 3, 2, 1], + unreadMentions: [], + filter: '' + } + }) + it('should update mentions', () => { + Mentions.mutations.appendMentions(state, 6) + expect(state.mentions).toEqual([6, 5, 4, 3, 2, 1]) + expect(state.unreadMentions).toEqual([]) + }) + }) + describe('not heading', () => { + beforeEach(() => { + state = { + lazyLoading: false, + heading: false, + mentions: [5, 4, 3, 2, 1], + unreadMentions: [], + filter: '' + } + }) + it('should update mentions', () => { + Mentions.mutations.appendMentions(state, 6) + expect(state.mentions).toEqual([5, 4, 3, 2, 1]) + expect(state.unreadMentions).toEqual([6]) + }) + }) + }) + + describe('mergeMentions', () => { + beforeEach(() => { + state = { + lazyLoading: false, + heading: false, + mentions: [5, 4, 3, 2, 1], + unreadMentions: [8, 7, 6], + filter: '' + } + }) + it('should be merged', () => { + Mentions.mutations.mergeMentions(state) + expect(state.mentions).toEqual([8, 7, 6, 5, 4, 3, 2, 1]) + expect(state.unreadMentions).toEqual([]) + }) + }) + + describe('insertMentions', () => { + beforeEach(() => { + state = { + lazyLoading: false, + heading: false, + mentions: [5, 4, 3, 2, 1], + unreadMentions: [], + filter: '' + } + }) + it('should be inserted', () => { + Mentions.mutations.insertMentions(state, [-1, -2, -3, -4]) + expect(state.mentions).toEqual([5, 4, 3, 2, 1, -1, -2, -3, -4]) + }) + }) + + describe('updateToot', () => { + beforeEach(() => { + state = { + lazyLoading: false, + heading: false, + mentions: [ + { type: 'mention', status: { id: 20, favourited: false } }, + { type: 'favourite', status: { id: 19, favourited: false } }, + { type: 'reblog', status: { id: 18, favourited: false } }, + { type: 'follow', status: { id: 17, favourited: false } }, + { type: 'mention', status: { id: 16, favourited: false } } + ], + unreadMentions: [], + filter: '' + } + }) + it('should be updated', () => { + Mentions.mutations.updateToot(state, { id: 20, favourited: true }) + expect(state.mentions).toEqual( + [ + { type: 'mention', status: { id: 20, favourited: true } }, + { type: 'favourite', status: { id: 19, favourited: false } }, + { type: 'reblog', status: { id: 18, favourited: false } }, + { type: 'follow', status: { id: 17, favourited: false } }, + { type: 'mention', status: { id: 16, favourited: false } } + ] + ) + }) + }) + }) +}) diff --git a/src/config/locales/en/translation.json b/src/config/locales/en/translation.json index 2e63297b..ebcf2f16 100644 --- a/src/config/locales/en/translation.json +++ b/src/config/locales/en/translation.json @@ -47,6 +47,7 @@ "expand": "Expand", "home": "Home", "notification": "Notification", + "mention": "Mention", "direct": "Direct messages", "favourite": "Favourite", "local": "Local timeline", @@ -58,6 +59,7 @@ "header_menu": { "home": "Home", "notification": "Notification", + "mention": "Mention", "favourite": "Favourite", "local": "Local timeline", "public": "Public timeline", diff --git a/src/main/index.js b/src/main/index.js index f47942cd..9204255d 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -413,6 +413,11 @@ ipcMain.on('start-user-streaming', (event, obj) => { }, (notification) => { event.sender.send('notification-start-user-streaming', notification) + // Does not exist a endpoint for only mention. And mention is a part of notification. + // So we have to get mention from notification. + if (notification.type === 'mention') { + event.sender.send('mention-start-user-streaming', notification) + } if (process.platform === 'darwin') { app.dock.setBadge('•') } diff --git a/src/renderer/components/TimelineSpace/Contents/Mentions.vue b/src/renderer/components/TimelineSpace/Contents/Mentions.vue new file mode 100644 index 00000000..3951f958 --- /dev/null +++ b/src/renderer/components/TimelineSpace/Contents/Mentions.vue @@ -0,0 +1,236 @@ + + + {{ unread.length > 0 ? unread.length : '' }} + + + + + + + + + + + + + + + + + + + + + diff --git a/src/renderer/components/TimelineSpace/Contents/Notifications.vue b/src/renderer/components/TimelineSpace/Contents/Notifications.vue index ee38a023..9a84553c 100644 --- a/src/renderer/components/TimelineSpace/Contents/Notifications.vue +++ b/src/renderer/components/TimelineSpace/Contents/Notifications.vue @@ -10,6 +10,7 @@ :filter="filter" :focused="message.id === focusedId" :overlaid="modalOpened" + v-on:update="updateToot" @focusNext="focusNext" @focusPrev="focusPrev" @focusRight="focusSidebar" @@ -148,6 +149,9 @@ export default { this.$store.commit('TimelineSpace/changeLoading', false) } }, + updateToot (message) { + this.$store.commit('TimelineSpace/Contents/Notifications/updateToot', message) + }, upper () { scrollTop( document.getElementById('scrollable'), diff --git a/src/renderer/components/TimelineSpace/HeaderMenu.vue b/src/renderer/components/TimelineSpace/HeaderMenu.vue index 8efce734..49c63ca6 100644 --- a/src/renderer/components/TimelineSpace/HeaderMenu.vue +++ b/src/renderer/components/TimelineSpace/HeaderMenu.vue @@ -103,6 +103,9 @@ export default { case 'favourites': this.$store.commit('TimelineSpace/HeaderMenu/updateTitle', this.$t('header_menu.favourite')) break + case 'mentions': + this.$store.commit('TimelineSpace/HeaderMenu/updateTitle', this.$t('header_menu.mention')) + break case 'local': this.$store.commit('TimelineSpace/HeaderMenu/updateTitle', this.$t('header_menu.local')) break @@ -148,6 +151,7 @@ export default { switch (this.$route.name) { case 'home': case 'notifications': + case 'mentions': case 'favourites': case 'local': case 'public': @@ -164,6 +168,7 @@ export default { switch (this.$route.name) { case 'home': case 'notifications': + case 'mentions': case 'favourites': case 'local': case 'public': @@ -185,6 +190,9 @@ export default { case 'notifications': this.filter = this.$store.state.TimelineSpace.Contents.Notifications.filter break + case 'mentions': + this.filter = this.$store.state.TimelineSpace.Contents.Mentions.filter + break case 'favourites': this.filter = this.$store.state.TimelineSpace.Contents.Favourites.filter break @@ -217,6 +225,9 @@ export default { case 'notifications': this.$store.commit('TimelineSpace/Contents/Notifications/changeFilter', filter) break + case 'mentions': + this.$store.commit('TimelineSpace/Contents/Mentions/changeFilter', filter) + break case 'favourites': this.$store.commit('TimelineSpace/Contents/Favourites/changeFilter', filter) break @@ -244,6 +255,7 @@ export default { switch (this.$route.name) { case 'home': case 'notifications': + case 'mentions': case 'favourites': case 'local': case 'public': diff --git a/src/renderer/components/TimelineSpace/SideMenu.vue b/src/renderer/components/TimelineSpace/SideMenu.vue index 419faa8b..8a85cae6 100644 --- a/src/renderer/components/TimelineSpace/SideMenu.vue +++ b/src/renderer/components/TimelineSpace/SideMenu.vue @@ -52,9 +52,11 @@ - - - {{ $t("side_menu.favourite") }} + + + {{ $t("side_menu.mention") }} + + @@ -62,6 +64,10 @@ + + + {{ $t("side_menu.favourite") }} + {{ $t("side_menu.local") }} @@ -118,6 +124,7 @@ export default { ...mapState('TimelineSpace/SideMenu', { unreadHomeTimeline: state => state.unreadHomeTimeline, unreadNotifications: state => state.unreadNotifications, + unreadMentions: state => state.unreadMentions, unreadLocalTimeline: state => state.unreadLocalTimeline, unreadDirectMessagesTimeline: state => state.unreadDirectMessagesTimeline, unreadPublicTimeline: state => state.unreadPublicTimeline, diff --git a/src/renderer/components/molecules/Notification.vue b/src/renderer/components/molecules/Notification.vue index d13c7932..3855988e 100644 --- a/src/renderer/components/molecules/Notification.vue +++ b/src/renderer/components/molecules/Notification.vue @@ -26,6 +26,8 @@ :filter="filter" :focused="focused" :overlaid="overlaid" + v-on:update="updateToot" + v-on:delete="deleteToot" @focusNext="$emit('focusNext')" @focusPrev="$emit('focusPrev')" @focusRight="$emit('focusRight')" @@ -72,7 +74,15 @@ export default { default: false } }, - components: { Favourite, Follow, Mention, Reblog } + components: { Favourite, Follow, Mention, Reblog }, + methods: { + updateToot (message) { + return this.$emit('update', message) + }, + deleteToot (message) { + return this.$emit('delete', message) + } + } } diff --git a/src/renderer/components/molecules/Notification/Mention.vue b/src/renderer/components/molecules/Notification/Mention.vue index db0451d3..d0f1ccda 100644 --- a/src/renderer/components/molecules/Notification/Mention.vue +++ b/src/renderer/components/molecules/Notification/Mention.vue @@ -6,6 +6,7 @@ :focused="focused" :overlaid="overlaid" v-on:update="updateToot" + v-on:delete="deleteToot" @focusNext="$emit('focusNext')" @focusPrev="$emit('focusPrev')" @focusRight="$emit('focusRight')" @@ -41,7 +42,10 @@ export default { components: { Toot }, methods: { updateToot (message) { - this.$store.commit('TimelineSpace/Contents/Notifications/updateToot', message) + return this.$emit('update', message) + }, + deleteToot (message) { + return this.$emit('delete', message) } } } diff --git a/src/renderer/router/index.js b/src/renderer/router/index.js index c0e1e2a6..acb452ee 100644 --- a/src/renderer/router/index.js +++ b/src/renderer/router/index.js @@ -82,6 +82,11 @@ export default new Router({ name: 'notifications', component: require('@/components/TimelineSpace/Contents/Notifications').default }, + { + path: 'mentions', + name: 'mentions', + component: require('@/components/TimelineSpace/Contents/Mentions').default + }, { path: 'favourites', name: 'favourites', diff --git a/src/renderer/store/TimelineSpace.js b/src/renderer/store/TimelineSpace.js index 6abf622d..eec98351 100644 --- a/src/renderer/store/TimelineSpace.js +++ b/src/renderer/store/TimelineSpace.js @@ -189,6 +189,7 @@ const TimelineSpace = { async fetchContentsTimelines ({ dispatch, state }, account) { await dispatch('TimelineSpace/Contents/Home/fetchTimeline', account, { root: true }) await dispatch('TimelineSpace/Contents/Notifications/fetchNotifications', account, { root: true }) + await dispatch('TimelineSpace/Contents/Mentions/fetchMentions', {}, { root: true }) if (state.unreadNotification.direct) { await dispatch('TimelineSpace/Contents/DirectMessages/fetchTimeline', {}, { root: true }) } @@ -205,6 +206,7 @@ const TimelineSpace = { commit('TimelineSpace/Contents/DirectMessages/clearTimeline', {}, { root: true }) commit('TimelineSpace/Contents/Notifications/clearNotifications', {}, { root: true }) commit('TimelineSpace/Contents/Public/clearTimeline', {}, { root: true }) + commit('TimelineSpace/Contents/Mentions/clearMentions', {}, { root: true }) }, bindStreamings ({ dispatch, state }, account) { dispatch('bindUserStreaming', account) @@ -267,6 +269,13 @@ const TimelineSpace = { } commit('TimelineSpace/SideMenu/changeUnreadNotifications', true, { root: true }) }) + ipcRenderer.on('mention-start-user-streaming', (event, mention) => { + commit('TimelineSpace/Contents/Mentions/appendMentions', mention, { root: true }) + if (rootState.TimelineSpace.Contents.Mentions.heading && Math.random() > 0.8) { + commit('TimelineSpace/Contents/Mentions/archiveMentions', null, { root: true }) + } + commit('TimelineSpace/SideMenu/changeUnreadMentions', true, { root: true }) + }) }, startUserStreaming ({ state }) { return new Promise((resolve, reject) => { diff --git a/src/renderer/store/TimelineSpace/Contents.js b/src/renderer/store/TimelineSpace/Contents.js index d1758451..81b91d7a 100644 --- a/src/renderer/store/TimelineSpace/Contents.js +++ b/src/renderer/store/TimelineSpace/Contents.js @@ -8,6 +8,7 @@ import Search from './Contents/Search' import Lists from './Contents/Lists' import Hashtag from './Contents/Hashtag' import DirectMessages from './Contents/DirectMessages' +import Mentions from './Contents/Mentions' const Contents = { namespaced: true, @@ -18,6 +19,7 @@ const Contents = { Favourites, Local, DirectMessages, + Mentions, Public, Search, Lists, diff --git a/src/renderer/store/TimelineSpace/Contents/Mentions.js b/src/renderer/store/TimelineSpace/Contents/Mentions.js new file mode 100644 index 00000000..b945928f --- /dev/null +++ b/src/renderer/store/TimelineSpace/Contents/Mentions.js @@ -0,0 +1,100 @@ +import Mastodon from 'megalodon' + +const Mentions = { + namespaced: true, + state: { + lazyLoading: false, + heading: true, + mentions: [], + unreadMentions: [], + filter: '' + }, + mutations: { + changeLazyLoading (state, value) { + state.lazyLoading = value + }, + changeHeading (state, value) { + state.heading = value + }, + appendMentions (state, update) { + if (state.heading) { + state.mentions = [update].concat(state.mentions) + } else { + state.unreadMentions = [update].concat(state.unreadMentions) + } + }, + updateMentions (state, messages) { + state.mentions = messages + }, + mergeMentions (state) { + state.mentions = state.unreadMentions.slice(0, 80).concat(state.mentions) + state.unreadMentions = [] + }, + insertMentions (state, messages) { + state.mentions = state.mentions.concat(messages) + }, + archiveMentions (state) { + state.mentions = state.mentions.slice(0, 40) + }, + clearMentions (state) { + state.mentions = [] + state.unreadMentions = [] + }, + updateToot (state, message) { + state.mentions = state.mentions.map((mention) => { + if (mention.type === 'mention' && mention.status.id === message.id) { + const status = { + status: message + } + return Object.assign(mention, status) + } else { + return mention + } + }) + }, + changeFilter (state, filter) { + state.filter = filter + } + }, + actions: { + fetchMentions ({ state, commit, rootState }) { + const client = new Mastodon( + rootState.TimelineSpace.account.accessToken, + rootState.TimelineSpace.account.baseURL + '/api/v1' + ) + return client.get('/notifications', { limit: 30, exclude_types: ['follow', 'favourite', 'reblog'] }) + .then(res => { + commit('updateMentions', res.data) + return res.data + }) + }, + lazyFetchMentions ({ state, commit, rootState }, last) { + if (last === undefined || last === null) { + return Promise.resolve(null) + } + if (state.lazyLoading) { + return Promise.resolve(null) + } + commit('changeLazyLoading', true) + const client = new Mastodon( + rootState.TimelineSpace.account.accessToken, + rootState.TimelineSpace.account.baseURL + '/api/v1' + ) + return client.get('/notifications', { max_id: last.id, limit: 30, exclude_types: ['follow', 'favourite', 'reblog'] }) + .then(res => { + commit('insertMentions', res.data) + return res.data + }) + .finally(() => { + commit('changeLazyLoading', false) + }) + } + }, + getters: { + mentions (state) { + return state.mentions.filter(mention => mention.type === 'mention') + } + } +} + +export default Mentions diff --git a/src/renderer/store/TimelineSpace/SideMenu.js b/src/renderer/store/TimelineSpace/SideMenu.js index db72d8cc..1342db52 100644 --- a/src/renderer/store/TimelineSpace/SideMenu.js +++ b/src/renderer/store/TimelineSpace/SideMenu.js @@ -6,6 +6,7 @@ const SideMenu = { state: { unreadHomeTimeline: false, unreadNotifications: false, + unreadMentions: false, unreadLocalTimeline: false, unreadDirectMessagesTimeline: false, unreadPublicTimeline: false, @@ -20,6 +21,9 @@ const SideMenu = { changeUnreadNotifications (state, value) { state.unreadNotifications = value }, + changeUnreadMentions (state, value) { + state.unreadMentions = value + }, changeUnreadLocalTimeline (state, value) { state.unreadLocalTimeline = value }, @@ -55,6 +59,7 @@ const SideMenu = { clearUnread ({ commit }) { commit('changeUnreadHomeTimeline', false) commit('changeUnreadNotifications', false) + commit('changeUnreadMentions', false) commit('changeUnreadLocalTimeline', false) commit('changeUnreadDirectMessagesTimeline', false) commit('changeUnreadPublicTimeline', false)