diff --git a/src/components/MessagesList/MessagesGroup/Message/MessagePart/MessageBody.vue b/src/components/MessagesList/MessagesGroup/Message/MessagePart/MessageBody.vue index 2a5bed512f6..3d627dde54d 100644 --- a/src/components/MessagesList/MessagesGroup/Message/MessagePart/MessageBody.vue +++ b/src/components/MessagesList/MessagesGroup/Message/MessagePart/MessageBody.vue @@ -141,6 +141,7 @@ import CallButton from '../../../../TopBar/CallButton.vue' import { useIsInCall } from '../../../../../composables/useIsInCall.js' import { useMessageInfo } from '../../../../../composables/useMessageInfo.js' import { EventBus } from '../../../../../services/EventBus.js' +import { usePollsStore } from '../../../../../stores/polls.js' import { parseSpecialSymbols, parseMentions } from '../../../../../utils/textParse.ts' // Regular expression to check for Unicode emojis in message text @@ -199,8 +200,10 @@ export default { isEditable, isFileShare, } = useMessageInfo(message) + return { isInCall: useIsInCall(), + pollsStore: usePollsStore(), isEditable, isFileShare, } @@ -241,7 +244,7 @@ export default { return false } - return this.isInCall && !!this.$store.getters.getNewPolls[this.message.messageParameters.object.id] + return this.isInCall && this.pollsStore.isNewPoll(this.message.messageParameters.object.id) }, isTemporary() { diff --git a/src/components/MessagesList/MessagesGroup/Message/MessagePart/Poll.vue b/src/components/MessagesList/MessagesGroup/Message/MessagePart/Poll.vue index 58573392d07..4c2197446ac 100644 --- a/src/components/MessagesList/MessagesGroup/Message/MessagePart/Poll.vue +++ b/src/components/MessagesList/MessagesGroup/Message/MessagePart/Poll.vue @@ -39,6 +39,7 @@ import { t } from '@nextcloud/l10n' import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' import { POLL } from '../../../../../constants.js' +import { usePollsStore } from '../../../../../stores/polls.js' export default { name: 'Poll', @@ -74,9 +75,15 @@ export default { }, }, + setup() { + return { + pollsStore: usePollsStore(), + } + }, + computed: { poll() { - return this.$store.getters.getPoll(this.token, this.id) + return this.pollsStore.getPoll(this.token, this.id) }, pollFooterText() { @@ -96,7 +103,7 @@ export default { t, getPollData() { if (!this.poll) { - this.$store.dispatch('getPollData', { + this.pollsStore.getPollData({ token: this.token, pollId: this.id, }) @@ -104,7 +111,7 @@ export default { }, openPoll() { - this.$store.dispatch('setActivePoll', { + this.pollsStore.setActivePoll({ token: this.token, pollId: this.id, name: this.name, diff --git a/src/components/NewMessage/NewMessagePollEditor.vue b/src/components/NewMessage/NewMessagePollEditor.vue index 8a8b2214730..de8c083313d 100644 --- a/src/components/NewMessage/NewMessagePollEditor.vue +++ b/src/components/NewMessage/NewMessagePollEditor.vue @@ -78,7 +78,7 @@ import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadi import NcDialog from '@nextcloud/vue/dist/Components/NcDialog.js' import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' -import pollService from '../../services/pollService.js' +import { usePollsStore } from '../../stores/polls.js' export default { name: 'NewMessagePollEditor', @@ -102,6 +102,12 @@ export default { emits: ['close'], + setup() { + return { + pollsStore: usePollsStore(), + } + }, + data() { return { isPrivate: false, @@ -140,21 +146,15 @@ export default { }, async createPoll() { - try { - const response = await pollService.postNewPoll( - this.token, - this.pollQuestion, - this.pollOptions, - this.isPrivate ? 1 : 0, - this.isMultipleAnswer ? 0 : 1) - // Add the poll immediately to the store - this.$store.dispatch('addPoll', { - token: this.token, - poll: response.data.ocs.data, - }) + const poll = await this.pollsStore.createPoll({ + token: this.token, + question: this.pollQuestion, + options: this.pollOptions, + resultMode: this.isPrivate ? 1 : 0, + maxVotes: this.isMultipleAnswer ? 0 : 1 + }) + if (poll) { this.dismissEditor() - } catch (error) { - console.debug(error) } }, diff --git a/src/components/PollViewer/PollViewer.vue b/src/components/PollViewer/PollViewer.vue index d0e0f617637..885d0f1d8b9 100644 --- a/src/components/PollViewer/PollViewer.vue +++ b/src/components/PollViewer/PollViewer.vue @@ -112,6 +112,7 @@ import { useId } from '../../composables/useId.ts' import { useIsInCall } from '../../composables/useIsInCall.js' import { POLL } from '../../constants.js' import { EventBus } from '../../services/EventBus.js' +import { usePollsStore } from '../../stores/polls.js' export default { name: 'PollViewer', @@ -138,6 +139,7 @@ export default { return { isInCall: useIsInCall(), + pollsStore: usePollsStore(), voteToSubmit, modalPage, loading, @@ -147,7 +149,7 @@ export default { computed: { activePoll() { - return this.$store.getters.activePoll + return this.pollsStore.activePoll }, name() { @@ -167,7 +169,7 @@ export default { }, poll() { - return this.$store.getters.getPoll(this.token, this.id) + return this.pollsStore.getPoll(this.token, this.id) }, selfHasVoted() { @@ -238,12 +240,12 @@ export default { }, id(value) { - this.$store.dispatch('hidePollToast', value) + this.pollsStore.hidePollToast(value) }, isInCall(value) { if (!value) { - this.$store.dispatch('hideAllPollToasts') + this.pollsStore.hideAllPollToasts() } }, @@ -274,7 +276,7 @@ export default { n, getPollData() { if (!this.poll) { - this.$store.dispatch('getPollData', { + this.pollsStore.getPollData({ token: this.token, pollId: this.id, }) @@ -292,21 +294,21 @@ export default { return } - this.$store.dispatch('addPollToast', { token, message }) + this.pollsStore.addPollToast({ token, message }) }, dismissModal() { - this.$store.dispatch('removeActivePoll') + this.pollsStore.removeActivePoll() this.voteToSubmit = [] }, async submitVote() { this.loading = true try { - await this.$store.dispatch('submitVote', { + await this.pollsStore.submitVote({ token: this.token, pollId: this.id, - vote: this.voteToSubmit.map(element => +element), + optionIds: this.voteToSubmit.map(element => +element), }) this.modalPage = 'results' } catch (error) { @@ -319,7 +321,7 @@ export default { async endPoll() { this.loading = true try { - await this.$store.dispatch('endPoll', { + await this.pollsStore.endPoll({ token: this.token, pollId: this.id, }) diff --git a/src/store/messagesStore.js b/src/store/messagesStore.js index a5432f86cd7..4ab15aed892 100644 --- a/src/store/messagesStore.js +++ b/src/store/messagesStore.js @@ -30,6 +30,7 @@ import { } from '../services/messagesService.ts' import { useChatExtrasStore } from '../stores/chatExtras.js' import { useGuestNameStore } from '../stores/guestName.js' +import { usePollsStore } from '../stores/polls.js' import { useReactionsStore } from '../stores/reactions.js' import { useSharedItemsStore } from '../stores/sharedItems.js' import CancelableRequest from '../utils/cancelableRequest.js' @@ -575,7 +576,8 @@ const actions = { } if (message.systemMessage === 'poll_voted') { - context.dispatch('debounceGetPollData', { + const pollsStore = usePollsStore() + pollsStore.debounceGetPollData({ token, pollId: message.messageParameters.poll.id, }) @@ -584,7 +586,8 @@ const actions = { } if (message.systemMessage === 'poll_closed') { - context.dispatch('getPollData', { + const pollsStore = usePollsStore() + pollsStore.getPollData({ token, pollId: message.messageParameters.poll.id, }) diff --git a/src/store/messagesStore.spec.js b/src/store/messagesStore.spec.js index 59527dcdf78..105efce446c 100644 --- a/src/store/messagesStore.spec.js +++ b/src/store/messagesStore.spec.js @@ -113,7 +113,6 @@ describe('messagesStore', () => { testStoreConfig.modules.conversationsStore.actions.updateConversationLastMessage = updateConversationLastMessageMock testStoreConfig.modules.conversationsStore.actions.updateConversationLastReadMessage = updateConversationLastReadMessageMock testStoreConfig.modules.conversationsStore.actions.updateConversationLastActive = updateConversationLastActiveAction - testStoreConfig.modules.pollStore.getters.debounceGetPollData = jest.fn() store = new Vuex.Store(testStoreConfig) }) diff --git a/src/store/pollStore.js b/src/store/pollStore.js deleted file mode 100644 index bc4774cb9e3..00000000000 --- a/src/store/pollStore.js +++ /dev/null @@ -1,186 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -import debounce from 'debounce' -import Vue from 'vue' - -import { showError, showInfo, TOAST_PERMANENT_TIMEOUT } from '@nextcloud/dialogs' -import { t } from '@nextcloud/l10n' - -import pollService from '../services/pollService.js' - -const state = { - polls: {}, - pollDebounceFunctions: {}, - activePoll: null, - pollToastsQueue: {}, -} - -const getters = { - getPoll: (state) => (token, id) => { - return state.polls?.[token]?.[id] - }, - - activePoll: (state) => { - return state.activePoll - }, - - getNewPolls: (state) => { - return state.pollToastsQueue - }, -} - -const mutations = { - addPoll(state, { token, poll }) { - if (!state.polls[token]) { - Vue.set(state.polls, token, {}) - } - Vue.set(state.polls[token], poll.id, poll) - }, - - setActivePoll(state, { token, pollId, name }) { - Vue.set(state, 'activePoll', { token, id: pollId, name }) - }, - - removeActivePoll(state) { - if (state.activePoll) { - Vue.set(state, 'activePoll', null) - } - }, - - addPollToast(state, { pollId, toast }) { - Vue.set(state.pollToastsQueue, pollId, toast) - }, - - hidePollToast(state, id) { - if (state.pollToastsQueue[id]) { - state.pollToastsQueue[id].hideToast() - Vue.delete(state.pollToastsQueue, id) - } - }, - - hideAllPollToasts(state) { - for (const id in state.pollToastsQueue) { - state.pollToastsQueue[id].hideToast() - Vue.delete(state.pollToastsQueue, id) - } - }, - - // Add debounce function for getting the poll data - addDebounceGetPollDataFunction(state, { token, pollId, debounceGetPollDataFunction }) { - if (!state.pollDebounceFunctions[token]) { - Vue.set(state.pollDebounceFunctions, token, {}) - } - Vue.set(state.pollDebounceFunctions[token], pollId, debounceGetPollDataFunction) - }, -} - -const actions = { - addPoll(context, { token, poll }) { - context.commit('addPoll', { token, poll }) - }, - - async getPollData(context, { token, pollId }) { - try { - const response = await pollService.getPollData(token, pollId) - const poll = response.data.ocs.data - context.dispatch('addPoll', { token, poll }) - console.debug('polldata', response) - } catch (error) { - console.debug(error) - } - }, - - /** - * In order to limit the amount of requests, we cannot get the - * poll data every time someone votes, so we create a debounce - * function for each poll and store it in the pollStore - * - * @param { object } context The store context - * @param { object } root0 The arguments passed to the action - * @param { string } root0.token The token of the conversation - * @param { number }root0.pollId The id of the poll - */ - debounceGetPollData(context, { token, pollId }) { - // Create debounce function for getting this particular poll data - // if it does not exist yet - if (!context.state.pollDebounceFunctions[token]?.[pollId]) { - const debounceGetPollDataFunction = debounce(async () => { - await context.dispatch('getPollData', { - token, - pollId, - }) - }, 5000) - // Add the debounce function to the state object - context.commit('addDebounceGetPollDataFunction', { - token, - pollId, - debounceGetPollDataFunction, - }) - } - // Call the debounce function for getting the poll data - context.state.pollDebounceFunctions[token][pollId]() - }, - - async submitVote(context, { token, pollId, vote }) { - console.debug('Submitting vote') - try { - const response = await pollService.submitVote(token, pollId, vote) - const poll = response.data.ocs.data - context.dispatch('addPoll', { token, poll }) - } catch (error) { - console.error(error) - showError(t('spreed', 'An error occurred while submitting your vote')) - } - }, - - async endPoll(context, { token, pollId }) { - console.debug('Ending poll') - try { - const response = await pollService.endPoll(token, pollId) - const poll = response.data.ocs.data - context.dispatch('addPoll', { token, poll }) - } catch (error) { - console.error(error) - showError(t('spreed', 'An error occurred while ending the poll')) - } - }, - - setActivePoll(context, { token, pollId, name }) { - context.commit('setActivePoll', { token, pollId, name }) - }, - - removeActivePoll(context) { - context.commit('removeActivePoll') - }, - - addPollToast(context, { token, message }) { - const pollId = message.messageParameters.object.id - const name = message.messageParameters.object.name - - const toast = showInfo(t('spreed', 'Poll "{name}" was created by {user}. Click to vote', { - name, - user: message.actorDisplayName, - }), { - onClick: () => { - if (!context.state.activePoll) { - context.dispatch('setActivePoll', { token, pollId, name }) - } - }, - timeout: TOAST_PERMANENT_TIMEOUT, - }) - - context.commit('addPollToast', { pollId, toast }) - }, - - hidePollToast(context, id) { - context.commit('hidePollToast', id) - }, - - hideAllPollToasts(context) { - context.commit('hideAllPollToasts') - }, -} - -export default { state, mutations, getters, actions } diff --git a/src/store/storeConfig.js b/src/store/storeConfig.js index b665726019d..f49a8fbc32d 100644 --- a/src/store/storeConfig.js +++ b/src/store/storeConfig.js @@ -10,7 +10,6 @@ import conversationsStore from './conversationsStore.js' import fileUploadStore from './fileUploadStore.js' import messagesStore from './messagesStore.js' import participantsStore from './participantsStore.js' -import pollStore from './pollStore.js' import sidebarStore from './sidebarStore.js' import soundsStore from './soundsStore.js' import tokenStore from './tokenStore.js' @@ -31,7 +30,6 @@ export default { tokenStore, uiModeStore, windowVisibilityStore, - pollStore, }, mutations: {}, diff --git a/src/stores/__tests__/polls.spec.js b/src/stores/__tests__/polls.spec.js new file mode 100644 index 00000000000..62a9f94bad2 --- /dev/null +++ b/src/stores/__tests__/polls.spec.js @@ -0,0 +1,194 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import flushPromises from 'flush-promises' +import { setActivePinia, createPinia } from 'pinia' + +import { ATTENDEE } from '../../constants.js' +import pollService from '../../services/pollService.js' +import { generateOCSResponse } from '../../test-helpers.js' +import { usePollsStore } from '../polls.js' + +jest.mock('../../services/pollService.js', () => ({ + postNewPoll: jest.fn(), + getPollData: jest.fn(), + submitVote: jest.fn(), + endPoll: jest.fn(), +})) + +describe('pollsStore', () => { + let pollsStore + const TOKEN = 'TOKEN' + const pollRequest = { + question: 'What is the answer to the universe?', + options: ['42', '24'], + resultMode: 0, + maxVotes: 1, + } + const poll = { + id: 1, + question: 'What is the answer to the universe?', + options: ['42', '24'], + votes: [], + numVoters: 0, + actorType: ATTENDEE.ACTOR_TYPE.USERS, + actorId: 'user', + actorDisplayName: 'User', + status: 0, + resultMode: 0, + maxVotes: 1, + votedSelf: [], + } + const pollWithVote = { + ...poll, + votes: { 'option-0': 1 }, + numVoters: 1, + votedSelf: [0], + } + const pollWithVoteEnded = { + ...pollWithVote, + status: 1, + details: [ + { + actorType: ATTENDEE.ACTOR_TYPE.USERS, + actorId: 'user', + actorDisplayName: 'User', + optionId: 0 + } + ] + } + const messageWithPoll = { + id: 123, + token: TOKEN, + actorType: ATTENDEE.ACTOR_TYPE.USERS, + actorId: 'user', + actorDisplayName: 'User', + message: '{object}', + messageType: 'comment', + messageParameters: { + actor: { + type: 'user', + id: 'user', + name: 'User', + }, + object: { + type: 'talk-poll', + id: poll.id, + name: poll.question, + } + }, + } + + beforeEach(async () => { + setActivePinia(createPinia()) + pollsStore = usePollsStore() + }) + + it('receives a poll from server and adds it to the store', async () => { + // Arrange + const response = generateOCSResponse({ payload: poll }) + pollService.getPollData.mockResolvedValue(response) + + // Act + await pollsStore.getPollData({ token: TOKEN, pollId: poll.id }) + + // Assert + expect(pollsStore.getPoll(TOKEN, poll.id)).toMatchObject(poll) + }) + + it('debounces a function to get a poll from server', async () => { + // Arrange + jest.useFakeTimers() + const response = generateOCSResponse({ payload: poll }) + pollService.getPollData.mockResolvedValue(response) + + // Act + pollsStore.debounceGetPollData({ token: TOKEN, pollId: poll.id }) + jest.advanceTimersByTime(5000) + await flushPromises() + + // Assert + expect(pollsStore.debouncedFunctions[TOKEN][poll.id]).toBeDefined() + expect(pollsStore.getPoll(TOKEN, poll.id)).toMatchObject(poll) + }) + + it('creates a poll and adds it to the store', async () => { + // Arrange + const response = generateOCSResponse({ payload: poll }) + pollService.postNewPoll.mockResolvedValue(response) + + // Act + await pollsStore.createPoll({ token: TOKEN, ...pollRequest }) + + // Assert + expect(pollsStore.getPoll(TOKEN, poll.id)).toMatchObject(poll) + }) + + it('submits a vote and updates it in the store', async () => { + // Arrange + pollsStore.addPoll({ token: TOKEN, poll }) + const response = generateOCSResponse({ payload: pollWithVote }) + pollService.submitVote.mockResolvedValue(response) + + // Act + await pollsStore.submitVote({ token: TOKEN, pollId: poll.id, optionIds: [0] }) + + // Assert + expect(pollsStore.getPoll(TOKEN, poll.id)).toMatchObject(pollWithVote) + }) + + it('ends a poll and updates it in the store', async () => { + // Arrange + pollsStore.addPoll({ token: TOKEN, poll: pollWithVote }) + const response = generateOCSResponse({ payload: pollWithVoteEnded }) + pollService.endPoll.mockResolvedValue(response) + + // Act + await pollsStore.endPoll({ token: TOKEN, pollId: poll.id }) + + // Assert + expect(pollsStore.getPoll(TOKEN, poll.id)).toMatchObject(pollWithVoteEnded) + }) + + it('adds poll toast to the queue from message', async () => { + // Act + pollsStore.addPollToast({ token: TOKEN, message: messageWithPoll }) + + // Assert + expect(pollsStore.isNewPoll(poll.id)).toBeTruthy() + }) + + it('sets active poll from the toast', async () => { + // Arrange + pollsStore.addPollToast({ token: TOKEN, message: messageWithPoll }) + + // Act + pollsStore.pollToastsQueue[poll.id].options.onClick() + + // Assert + expect(pollsStore.activePoll).toMatchObject({ token: TOKEN, id: poll.id, name: poll.question }) + }) + + it('removes active poll', async () => { + // Arrange + pollsStore.setActivePoll({ token: TOKEN, pollId: poll.id, name: poll.question }) + + // Act + pollsStore.removeActivePoll() + + // Assert + expect(pollsStore.activePoll).toEqual(null) + }) + + it('hides all poll toasts', async () => { + // Arrange + pollsStore.addPollToast({ token: TOKEN, message: messageWithPoll }) + + // Act + pollsStore.hideAllPollToasts() + + // Assert + expect(pollsStore.pollToastsQueue).toMatchObject({}) + }) +}) diff --git a/src/stores/polls.js b/src/stores/polls.js new file mode 100644 index 00000000000..83302c898b0 --- /dev/null +++ b/src/stores/polls.js @@ -0,0 +1,152 @@ +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import debounce from 'debounce' +import { defineStore } from 'pinia' +import Vue from 'vue' + +import { showError, showInfo, TOAST_PERMANENT_TIMEOUT } from '@nextcloud/dialogs' +import { t } from '@nextcloud/l10n' + +import pollService from '../services/pollService.js' + +export const usePollsStore = defineStore('polls', { + state: () => ({ + polls: {}, + debouncedFunctions: {}, + activePoll: null, + pollToastsQueue: {}, + }), + + getters: { + getPoll: (state) => (token, pollId) => { + return state.polls[token]?.[pollId] + }, + + isNewPoll: (state) => (pollId) => { + return state.pollToastsQueue[pollId] !== undefined + }, + }, + + actions: { + addPoll({ token, poll }) { + if (!this.polls[token]) { + Vue.set(this.polls, token, {}) + } + Vue.set(this.polls[token], poll.id, poll) + }, + + async getPollData({ token, pollId }) { + try { + const response = await pollService.getPollData(token, pollId) + this.addPoll({ token, poll: response.data.ocs.data }) + } catch (error) { + console.error(error) + } + }, + + /** + * In order to limit the amount of requests, we cannot get the + * poll data every time someone votes, so we create a debounce + * function for each poll and store it in the pollStore + * + * @param { object } root0 The arguments passed to the action + * @param { string } root0.token The token of the conversation + * @param { number } root0.pollId The id of the poll + */ + debounceGetPollData({ token, pollId }) { + if (!this.debouncedFunctions[token]) { + Vue.set(this.debouncedFunctions, token, {}) + } + // Create the debounced function for getting poll data if not exist yet + if (!this.debouncedFunctions[token]?.[pollId]) { + const debouncedFunction = debounce(async () => { + await this.getPollData({ token, pollId }) + }, 5000) + Vue.set(this.debouncedFunctions[token], pollId, debouncedFunction) + } + // Call the debounced function for getting the poll data + this.debouncedFunctions[token][pollId]() + }, + + async createPoll({ token, question, options, resultMode, maxVotes }) { + try { + const response = await pollService.postNewPoll( + token, + question, + options, + resultMode, + maxVotes, + ) + this.addPoll({ token, poll: response.data.ocs.data }) + + return response.data.ocs.data + } catch (error) { + console.error(error) + } + }, + + async submitVote({ token, pollId, optionIds }) { + try { + const response = await pollService.submitVote(token, pollId, optionIds) + this.addPoll({ token, poll: response.data.ocs.data }) + } catch (error) { + console.error(error) + showError(t('spreed', 'An error occurred while submitting your vote')) + } + }, + + async endPoll({ token, pollId }) { + try { + const response = await pollService.endPoll(token, pollId) + this.addPoll({ token, poll: response.data.ocs.data }) + } catch (error) { + console.error(error) + showError(t('spreed', 'An error occurred while ending the poll')) + } + }, + + setActivePoll({ token, pollId, name }) { + Vue.set(this, 'activePoll', { token, id: pollId, name }) + }, + + removeActivePoll() { + if (this.activePoll) { + Vue.set(this, 'activePoll', null) + } + }, + + addPollToast({ token, message }) { + const pollId = message.messageParameters.object.id + const name = message.messageParameters.object.name + + const toast = showInfo(t('spreed', 'Poll "{name}" was created by {user}. Click to vote', { + name, + user: message.actorDisplayName, + }), { + onClick: () => { + if (!this.activePoll) { + this.setActivePoll({ token, pollId, name }) + } + }, + timeout: TOAST_PERMANENT_TIMEOUT, + }) + + Vue.set(this.pollToastsQueue, pollId, toast) + }, + + hidePollToast(pollId) { + if (this.pollToastsQueue[pollId]) { + this.pollToastsQueue[pollId].hideToast() + Vue.delete(this.pollToastsQueue, pollId) + } + }, + + hideAllPollToasts() { + for (const pollId in this.pollToastsQueue) { + this.hidePollToast(pollId) + } + }, + }, +})