diff --git a/src/components/RightSidebar/Participants/ParticipantsTab.vue b/src/components/RightSidebar/Participants/ParticipantsTab.vue index f8b1b2b4891..0f80c5d4bfc 100644 --- a/src/components/RightSidebar/Participants/ParticipantsTab.vue +++ b/src/components/RightSidebar/Participants/ParticipantsTab.vue @@ -70,7 +70,6 @@ import getParticipants from '../../../mixins/getParticipants.js' import { searchPossibleConversations } from '../../../services/conversationsService.js' import { EventBus } from '../../../services/EventBus.js' import { addParticipant } from '../../../services/participantsService.js' -import { useGuestNameStore } from '../../../stores/guestName.js' import CancelableRequest from '../../../utils/cancelableRequest.js' export default { @@ -99,11 +98,8 @@ export default { setup() { const { sortParticipants } = useSortParticipants() - // FIXME move to getParticipants when replace with composable - const guestNameStore = useGuestNameStore() return { - guestNameStore, sortParticipants, } }, diff --git a/src/components/RightSidebar/RightSidebar.vue b/src/components/RightSidebar/RightSidebar.vue index 727777f0577..af4eb0d2900 100644 --- a/src/components/RightSidebar/RightSidebar.vue +++ b/src/components/RightSidebar/RightSidebar.vue @@ -325,9 +325,14 @@ export default { } }, - // Switch tab for guest if he is demoted from moderators isModeratorOrUser(newValue) { - if (!newValue) { + if (newValue) { + // Fetch participants list if guest was promoted to moderators + this.$nextTick(() => { + emit('guest-promoted', { token: this.token }) + }) + } else { + // Switch active tab to chat if guest was demoted from moderators this.activeTab = 'chat' } }, diff --git a/src/components/TopBar/TopBar.vue b/src/components/TopBar/TopBar.vue index 373a87b8a64..ffaa256ffda 100644 --- a/src/components/TopBar/TopBar.vue +++ b/src/components/TopBar/TopBar.vue @@ -164,9 +164,8 @@ import TopBarMediaControls from './TopBarMediaControls.vue' import TopBarMenu from './TopBarMenu.vue' import { CONVERSATION } from '../../constants.js' -import getParticipants from '../../mixins/getParticipants.js' +import isInLobby from '../../mixins/isInLobby.js' import BrowserStorage from '../../services/BrowserStorage.js' -import { useGuestNameStore } from '../../stores/guestName.js' import { getStatusMessage } from '../../utils/userStatus.js' import { localCallParticipantModel, localMediaModel } from '../../utils/webrtc/index.js' @@ -196,7 +195,7 @@ export default { mixins: [ richEditor, - getParticipants, + isInLobby, ], props: { @@ -214,15 +213,6 @@ export default { }, }, - setup() { - // FIXME move to getParticipants when replace with composable - const guestNameStore = useGuestNameStore() - - return { - guestNameStore, - } - }, - data: () => { return { unreadNotificationHandle: null, @@ -353,18 +343,6 @@ export default { this.notifyUnreadMessages(null) } }, - - isModeratorOrUser(newValue) { - if (newValue) { - // fetch participants immediately when becomes available - this.cancelableGetParticipants() - } - }, - }, - - beforeMount() { - // Initialises the get participants mixin for participants counter - this.initialiseGetParticipantsMixin() }, mounted() { @@ -382,8 +360,6 @@ export default { document.removeEventListener('MSFullscreenChange', this.fullScreenChanged, false) document.removeEventListener('webkitfullscreenchange', this.fullScreenChanged, false) document.body.classList.remove('has-topbar') - - this.stopGetParticipantsMixin() }, methods: { diff --git a/src/mixins/getParticipants.js b/src/mixins/getParticipants.js index d7ae961e457..3a27393ab76 100644 --- a/src/mixins/getParticipants.js +++ b/src/mixins/getParticipants.js @@ -20,18 +20,11 @@ * along with this program. If not, see . * */ -import Hex from 'crypto-js/enc-hex.js' -import SHA1 from 'crypto-js/sha1.js' import debounce from 'debounce' -import Axios from '@nextcloud/axios' -import { showError } from '@nextcloud/dialogs' -import { emit } from '@nextcloud/event-bus' +import { subscribe, unsubscribe } from '@nextcloud/event-bus' -import { PARTICIPANT } from '../constants.js' import { EventBus } from '../services/EventBus.js' -import { fetchParticipants } from '../services/participantsService.js' -import CancelableRequest from '../utils/cancelableRequest.js' import isInLobby from './isInLobby.js' const getParticipants = { @@ -41,10 +34,6 @@ const getParticipants = { data() { return { participantsInitialised: false, - /** - * Stores the cancel function for cancelableGetParticipants - */ - cancelGetParticipants: () => {}, fetchingParticipants: false, } }, @@ -65,12 +54,16 @@ const getParticipants = { // Then we have to search for another solution. Maybe the room list which we update // periodically gets a hash of all online sessions? EventBus.$on('signaling-participant-list-changed', this.debounceUpdateParticipants) + + subscribe('guest-promoted', this.onJoinedConversation) }, stopGetParticipantsMixin() { EventBus.$off('route-change', this.onRouteChange) EventBus.$off('joined-conversation', this.onJoinedConversation) EventBus.$off('signaling-participant-list-changed', this.debounceUpdateParticipants) + + unsubscribe('guest-promoted', this.onJoinedConversation) }, onRouteChange() { @@ -116,53 +109,13 @@ const getParticipants = { return } - try { - // The token must be stored in a local variable to ensure that - // the same token is used after waiting. - const token = this.token - // Clear previous requests if there's one pending - this.cancelGetParticipants('Cancel get participants') - // Get a new cancelable request function and cancel function pair - this.fetchingParticipants = true - const { request, cancel } = CancelableRequest(fetchParticipants) - this.cancelGetParticipants = cancel - const participants = await request(token) - this.$store.dispatch('purgeParticipantsStore', token) - - const hasUserStatuses = !!participants.headers['x-nextcloud-has-user-statuses'] - participants.data.ocs.data.forEach(participant => { - this.$store.dispatch('addParticipant', { - token, - participant, - }) - if (participant.participantType === PARTICIPANT.TYPE.GUEST - || participant.participantType === PARTICIPANT.TYPE.GUEST_MODERATOR) { - // FIXME replace mixin with composable. until then - // guestNameStore should be set up at component level - this.guestNameStore.addGuestName({ - token, - actorId: Hex.stringify(SHA1(participant.sessionIds[0])), - actorDisplayName: participant.displayName, - }, { noUpdate: false }) - } else if (participant.actorType === 'users' && hasUserStatuses) { - emit('user_status:status.updated', { - status: participant.status, - message: participant.statusMessage, - icon: participant.statusIcon, - clearAt: participant.statusClearAt, - userId: participant.actorId, - }) - } - }) + this.fetchingParticipants = true + + const response = await this.$store.dispatch('fetchParticipants', { token: this.token }) + if (response) { this.participantsInitialised = true - } catch (exception) { - if (!Axios.isCancel(exception)) { - console.error(exception) - showError(t('spreed', 'An error occurred while fetching the participants')) - } - } finally { - this.fetchingParticipants = false } + this.fetchingParticipants = false }, }, } diff --git a/src/store/participantsStore.js b/src/store/participantsStore.js index a0c09a33026..40fe7a6b3cd 100644 --- a/src/store/participantsStore.js +++ b/src/store/participantsStore.js @@ -19,9 +19,12 @@ * along with this program. If not, see . * */ +import Hex from 'crypto-js/enc-hex.js' +import SHA1 from 'crypto-js/sha1.js' import Vue from 'vue' import { showError } from '@nextcloud/dialogs' +import { emit } from '@nextcloud/event-bus' import { generateUrl } from '@nextcloud/router' import { PARTICIPANT } from '../constants.js' @@ -43,9 +46,12 @@ import { removeAllPermissionsFromParticipant, setPermissions, setTyping, + fetchParticipants, } from '../services/participantsService.js' import SessionStorage from '../services/SessionStorage.js' import { talkBroadcastChannel } from '../services/talkBroadcastChannel.js' +import { useGuestNameStore } from '../stores/guestName.js' +import CancelableRequest from '../utils/cancelableRequest.js' const state = { attendees: { @@ -60,6 +66,12 @@ const state = { }, speaking: { }, + /** + * Stores the cancel function returned by `cancelableFetchParticipants`, + * which allows to cancel the previous request for participants + * when quickly switching to a new conversation. + */ + cancelFetchParticipants: null, } const getters = { @@ -414,6 +426,10 @@ const mutations = { Vue.delete(state.peers, token) } }, + + setCancelFetchParticipants(state, cancelFunction) { + state.cancelFetchParticipants = cancelFunction + }, } const actions = { @@ -536,6 +552,81 @@ const actions = { commit('updateParticipant', { token, attendeeId: attendee.attendeeId, updatedData }) }, + /** + * Fetches participants that belong to a particular conversation + * specified with its token. + * + * @param {object} context default store context; + * @param {object} data the wrapping object; + * @param {string} data.token the conversation token; + * @return {object|null} + */ + async fetchParticipants(context, { token }) { + const guestNameStore = useGuestNameStore() + // Cancel a previous request + context.dispatch('cancelFetchParticipants') + // Get a new cancelable request function and cancel function pair + const { request, cancel } = CancelableRequest(fetchParticipants) + // Assign the new cancel function to our data value + context.commit('setCancelFetchParticipants', cancel) + + try { + const response = await request(token) + context.dispatch('purgeParticipantsStore', token) + + const hasUserStatuses = !!response.headers['x-nextcloud-has-user-statuses'] + + response.data.ocs.data.forEach(participant => { + context.dispatch('addParticipant', { token, participant }) + + if (participant.participantType === PARTICIPANT.TYPE.GUEST + || participant.participantType === PARTICIPANT.TYPE.GUEST_MODERATOR) { + guestNameStore.addGuestName({ + token, + actorId: Hex.stringify(SHA1(participant.sessionIds[0])), + actorDisplayName: participant.displayName, + }, { noUpdate: false }) + } else if (participant.actorType === 'users' && hasUserStatuses) { + emit('user_status:status.updated', { + status: participant.status, + message: participant.statusMessage, + icon: participant.statusIcon, + clearAt: participant.statusClearAt, + userId: participant.actorId, + }) + } + }) + + // Discard current cancel function + context.commit('setCancelFetchParticipants', null) + + return response + } catch (exception) { + if (exception?.response.status === 403) { + context.dispatch('fetchConversation', { token }) + } else if (!CancelableRequest.isCancel(exception)) { + console.error(exception) + showError(t('spreed', 'An error occurred while fetching the participants')) + } + return null + } + }, + + /** + * Cancels a previously running "fetchParticipants" action if applicable. + * + * @param {object} context default store context; + * @return {boolean} true if a request got cancelled, false otherwise + */ + cancelFetchParticipants(context) { + if (context.state.cancelFetchParticipants) { + context.state.cancelFetchParticipants('canceled') + context.commit('setCancelFetchParticipants', null) + return true + } + return false + }, + async joinCall({ commit, getters }, { token, participantIdentifier, flags, silent }) { if (!participantIdentifier?.sessionId) { console.error('Trying to join call without sessionId') diff --git a/src/store/participantsStore.spec.js b/src/store/participantsStore.spec.js index 67018fa41aa..d01d4e269c7 100644 --- a/src/store/participantsStore.spec.js +++ b/src/store/participantsStore.spec.js @@ -1,13 +1,19 @@ import { createLocalVue } from '@vue/test-utils' +import Hex from 'crypto-js/enc-hex.js' +import SHA1 from 'crypto-js/sha1.js' import mockConsole from 'jest-mock-console' import { cloneDeep } from 'lodash' +import { createPinia, setActivePinia } from 'pinia' import Vuex from 'vuex' +import { emit } from '@nextcloud/event-bus' + import { PARTICIPANT } from '../constants.js' import { joinCall, leaveCall, } from '../services/callsService.js' +import { fetchConversation } from '../services/conversationsService.js' import { EventBus } from '../services/EventBus.js' import { promoteToModerator, @@ -16,12 +22,15 @@ import { resendInvitations, joinConversation, leaveConversation, + fetchParticipants, removeCurrentUserFromConversation, grantAllPermissionsToParticipant, removeAllPermissionsFromParticipant, } from '../services/participantsService.js' +import { useGuestNameStore } from '../stores/guestName.js' import { generateOCSErrorResponse, generateOCSResponse } from '../test-helpers.js' import participantsStore from './participantsStore.js' +import storeConfig from './storeConfig.js' jest.mock('../services/participantsService', () => ({ promoteToModerator: jest.fn(), @@ -30,6 +39,7 @@ jest.mock('../services/participantsService', () => ({ resendInvitations: jest.fn(), joinConversation: jest.fn(), leaveConversation: jest.fn(), + fetchParticipants: jest.fn(), removeCurrentUserFromConversation: jest.fn(), grantAllPermissionsToParticipant: jest.fn(), removeAllPermissionsFromParticipant: jest.fn(), @@ -38,16 +48,27 @@ jest.mock('../services/callsService', () => ({ joinCall: jest.fn(), leaveCall: jest.fn(), })) +jest.mock('../services/conversationsService', () => ({ + fetchConversation: jest.fn(), +})) + +jest.mock('@nextcloud/event-bus', () => ({ + emit: jest.fn(), + subscribe: jest.fn(), +})) describe('participantsStore', () => { const TOKEN = 'XXTOKENXX' let testStoreConfig = null let localVue = null let store = null + let guestNameStore = null beforeEach(() => { localVue = createLocalVue() localVue.use(Vuex) + setActivePinia(createPinia()) + guestNameStore = useGuestNameStore() testStoreConfig = cloneDeep(participantsStore) store = new Vuex.Store(testStoreConfig) @@ -359,12 +380,124 @@ describe('participantsStore', () => { }) }) - describe('call handling', () => { - beforeEach(() => { + describe('fetch participants', () => { + test('populates store for the fetched conversation', async () => { + // Arrange + const payload = [{ + attendeeId: 1, + sessionId: 'session-id-1', + inCall: PARTICIPANT.CALL_FLAG.DISCONNECTED, + }] + + fetchParticipants.mockResolvedValue(generateOCSResponse({ payload })) + + // Act + await store.dispatch('fetchParticipants', { token: TOKEN }) + + // Assert + expect(store.getters.participantsList(TOKEN)).toMatchObject(payload) + }) + + test('saves a guest name from response', async () => { + // Arrange + const payload = [{ + attendeeId: 1, + sessionIds: ['guest-session-id'], + actorId: 'guest-actor-id', + displayName: 'guest-name', + participantType: PARTICIPANT.TYPE.GUEST, + inCall: PARTICIPANT.CALL_FLAG.DISCONNECTED, + }] + const id = Hex.stringify(SHA1('guest-session-id')) + + fetchParticipants.mockResolvedValue(generateOCSResponse({ payload })) + + // Act + await store.dispatch('fetchParticipants', { token: TOKEN }) + + // Assert + expect(guestNameStore.getGuestName(TOKEN, id)).toBe('guest-name') + }) + + test('emits an user status update', async () => { + // Arrange + const payload = [{ + attendeeId: 1, + actorId: 'actor-id', + displayName: 'guest-name', + actorType: 'users', + inCall: PARTICIPANT.CALL_FLAG.DISCONNECTED, + status: 'status', + statusMessage: 'statusMessage', + statusIcon: 'statusIcon', + statusClearAt: 'statusClearAt', + }] + + fetchParticipants.mockResolvedValue(generateOCSResponse( + { + headers: { 'x-nextcloud-has-user-statuses': true }, + payload, + })) + + // Act + await store.dispatch('fetchParticipants', { token: TOKEN }) + + // Assert + expect(emit).toHaveBeenCalledWith('user_status:status.updated', + { + clearAt: 'statusClearAt', + icon: 'statusIcon', + message: 'statusMessage', + status: 'status', + userId: 'actor-id', + }) + }) + + test('updates conversation if fail to fetch participants', async () => { + // Arrange + testStoreConfig = cloneDeep(storeConfig) store = new Vuex.Store(testStoreConfig) + fetchParticipants.mockRejectedValue(generateOCSErrorResponse({ + status: 403, + payload: [], + })) + fetchConversation.mockResolvedValue(generateOCSResponse( + { + payload: {}, + })) + // Act + await store.dispatch('fetchParticipants', { token: TOKEN }) + + // Assert + expect(fetchConversation).toHaveBeenCalled() }) - test('joins call', async () => { + test('cancels old request', async () => { + // Arrange + const payload = [{ + attendeeId: 1, + sessionId: 'session-id-1', + inCall: PARTICIPANT.CALL_FLAG.DISCONNECTED, + }] + fetchParticipants.mockResolvedValue(generateOCSResponse({ payload })) + + // Act + store.dispatch('fetchParticipants', { token: TOKEN }) + await store.dispatch('fetchParticipants', { token: TOKEN }) + + // Assert + expect(fetchParticipants).toHaveBeenCalledTimes(2) + expect(fetchParticipants).toHaveBeenNthCalledWith(1, TOKEN, { cancelToken: { promise: expect.anything(), reason: expect.anything() } }) + expect(fetchParticipants).toHaveBeenNthCalledWith(2, TOKEN, { cancelToken: { promise: expect.anything() } }) + }) + }) + + describe('call handling', () => { + const actualFlags = PARTICIPANT.CALL_FLAG.WITH_AUDIO + const flags = PARTICIPANT.CALL_FLAG.WITH_AUDIO | PARTICIPANT.CALL_FLAG.WITH_VIDEO + + beforeEach(async () => { + store = new Vuex.Store(testStoreConfig) store.dispatch('addParticipant', { token: TOKEN, participant: { @@ -377,13 +510,9 @@ describe('participantsStore', () => { // The requested flags and the actual flags can be different if some // media device is not available. - const actualFlags = PARTICIPANT.CALL_FLAG.WITH_AUDIO joinCall.mockResolvedValue(actualFlags) + leaveCall.mockResolvedValue() - expect(store.getters.isInCall(TOKEN)).toBe(false) - expect(store.getters.isConnecting(TOKEN)).toBe(false) - - const flags = PARTICIPANT.CALL_FLAG.WITH_AUDIO | PARTICIPANT.CALL_FLAG.WITH_VIDEO await store.dispatch('joinCall', { token: TOKEN, participantIdentifier: { @@ -393,9 +522,13 @@ describe('participantsStore', () => { flags, silent: false, }) + }) + test('joins call', async () => { + // Assert expect(joinCall).toHaveBeenCalledWith(TOKEN, flags, false) expect(store.getters.isInCall(TOKEN)).toBe(true) + expect(store.getters.isConnecting(TOKEN)).toBe(true) expect(store.getters.participantsList(TOKEN)).toStrictEqual([ { attendeeId: 1, @@ -405,84 +538,36 @@ describe('participantsStore', () => { }, ]) - expect(store.getters.isConnecting(TOKEN)).toBe(true) - + // Finished connecting to the call EventBus.$emit('signaling-users-in-room') expect(store.getters.isInCall(TOKEN)).toBe(true) expect(store.getters.isConnecting(TOKEN)).toBe(false) }) - }) - - test('joins and leaves call', async () => { - store.dispatch('addParticipant', { - token: TOKEN, - participant: { - attendeeId: 1, - sessionId: 'session-id-1', - participantType: PARTICIPANT.TYPE.USER, - inCall: PARTICIPANT.CALL_FLAG.DISCONNECTED, - }, - }) - - // The requested flags and the actual flags can be different if some - // media device is not available. - const actualFlags = PARTICIPANT.CALL_FLAG.WITH_AUDIO - joinCall.mockResolvedValue(actualFlags) - - expect(store.getters.isInCall(TOKEN)).toBe(false) - expect(store.getters.isConnecting(TOKEN)).toBe(false) - - const flags = PARTICIPANT.CALL_FLAG.WITH_AUDIO | PARTICIPANT.CALL_FLAG.WITH_VIDEO - await store.dispatch('joinCall', { - token: TOKEN, - participantIdentifier: { - attendeeId: 1, - sessionId: 'session-id-1', - }, - flags, - silent: false, - }) - expect(joinCall).toHaveBeenCalledWith(TOKEN, flags, false) - expect(store.getters.isInCall(TOKEN)).toBe(true) - expect(store.getters.participantsList(TOKEN)).toStrictEqual([ - { - attendeeId: 1, - sessionId: 'session-id-1', - inCall: actualFlags, - participantType: PARTICIPANT.TYPE.USER, - }, - ]) - - expect(store.getters.isConnecting(TOKEN)).toBe(true) - - EventBus.$emit('signaling-users-in-room') - - expect(store.getters.isInCall(TOKEN)).toBe(true) - expect(store.getters.isConnecting(TOKEN)).toBe(false) - - leaveCall.mockResolvedValue() + test('leaves call', async () => { + // Act + await store.dispatch('leaveCall', { + token: TOKEN, + participantIdentifier: { + attendeeId: 1, + sessionId: 'session-id-1', + }, + }) - await store.dispatch('leaveCall', { - token: TOKEN, - participantIdentifier: { - attendeeId: 1, - sessionId: 'session-id-1', - }, + // Assert + expect(leaveCall).toHaveBeenCalledWith(TOKEN, false) + expect(store.getters.isInCall(TOKEN)).toBe(false) + expect(store.getters.isConnecting(TOKEN)).toBe(false) + expect(store.getters.participantsList(TOKEN)).toStrictEqual([ + { + attendeeId: 1, + sessionId: 'session-id-1', + inCall: PARTICIPANT.CALL_FLAG.DISCONNECTED, + participantType: PARTICIPANT.TYPE.USER, + }, + ]) }) - - expect(leaveCall).toHaveBeenCalledWith(TOKEN, false) - expect(store.getters.isInCall(TOKEN)).toBe(false) - expect(store.getters.isConnecting(TOKEN)).toBe(false) - expect(store.getters.participantsList(TOKEN)).toStrictEqual([ - { - attendeeId: 1, - sessionId: 'session-id-1', - inCall: PARTICIPANT.CALL_FLAG.DISCONNECTED, - participantType: PARTICIPANT.TYPE.USER, - }, - ]) }) test('resends invitations', async () => { @@ -662,68 +747,70 @@ describe('participantsStore', () => { }) }) - test('leaves conversation', async () => { - leaveConversation.mockResolvedValue() + describe('leaving conversation', () => { + test('leaves conversation', async () => { + leaveConversation.mockResolvedValue() - await store.dispatch('leaveConversation', { token: TOKEN }) - - expect(leaveCall).not.toHaveBeenCalled() - expect(leaveConversation).toHaveBeenCalledWith(TOKEN) - }) + await store.dispatch('leaveConversation', { token: TOKEN }) - test('leaves conversation while in call', async () => { - testStoreConfig.getters.getParticipantIdentifier = () => jest.fn().mockReturnValue({ - attendeeId: 1, - sessionId: 'session-id-1', + expect(leaveCall).not.toHaveBeenCalled() + expect(leaveConversation).toHaveBeenCalledWith(TOKEN) }) - store = new Vuex.Store(testStoreConfig) - store.dispatch('addParticipant', { - token: TOKEN, - participant: { + test('leaves conversation while in call', async () => { + testStoreConfig.getters.getParticipantIdentifier = () => jest.fn().mockReturnValue({ attendeeId: 1, sessionId: 'session-id-1', - participantType: PARTICIPANT.TYPE.USER, - inCall: PARTICIPANT.CALL_FLAG.DISCONNECTED, - }, - }) + }) + store = new Vuex.Store(testStoreConfig) - const flags = PARTICIPANT.CALL_FLAG.WITH_AUDIO | PARTICIPANT.CALL_FLAG.WITH_VIDEO - await store.dispatch('joinCall', { - token: TOKEN, - participantIdentifier: { - attendeeId: 1, - sessionId: 'session-id-1', - }, - flags, - silent: false, - }) + store.dispatch('addParticipant', { + token: TOKEN, + participant: { + attendeeId: 1, + sessionId: 'session-id-1', + participantType: PARTICIPANT.TYPE.USER, + inCall: PARTICIPANT.CALL_FLAG.DISCONNECTED, + }, + }) - expect(store.getters.isInCall(TOKEN)).toBe(true) + const flags = PARTICIPANT.CALL_FLAG.WITH_AUDIO | PARTICIPANT.CALL_FLAG.WITH_VIDEO + await store.dispatch('joinCall', { + token: TOKEN, + participantIdentifier: { + attendeeId: 1, + sessionId: 'session-id-1', + }, + flags, + silent: false, + }) - leaveConversation.mockResolvedValue() + expect(store.getters.isInCall(TOKEN)).toBe(true) - await store.dispatch('leaveConversation', { token: TOKEN }) + leaveConversation.mockResolvedValue() - expect(store.getters.isInCall(TOKEN)).toBe(false) - expect(leaveCall).toHaveBeenCalledWith(TOKEN, false) - expect(leaveConversation).toHaveBeenCalledWith(TOKEN) - }) + await store.dispatch('leaveConversation', { token: TOKEN }) - test('removes current user from conversation', async () => { - removeCurrentUserFromConversation.mockResolvedValue() + expect(store.getters.isInCall(TOKEN)).toBe(false) + expect(leaveCall).toHaveBeenCalledWith(TOKEN, false) + expect(leaveConversation).toHaveBeenCalledWith(TOKEN) + }) - testStoreConfig = cloneDeep(participantsStore) - testStoreConfig.actions.deleteConversation = jest.fn() - store = new Vuex.Store(testStoreConfig) + test('removes current user from conversation', async () => { + removeCurrentUserFromConversation.mockResolvedValue() + + testStoreConfig = cloneDeep(participantsStore) + testStoreConfig.actions.deleteConversation = jest.fn() + store = new Vuex.Store(testStoreConfig) - await store.dispatch('removeCurrentUserFromConversation', { token: TOKEN }) + await store.dispatch('removeCurrentUserFromConversation', { token: TOKEN }) - expect(removeCurrentUserFromConversation).toHaveBeenCalledWith(TOKEN) - expect(testStoreConfig.actions.deleteConversation).toHaveBeenCalledWith(expect.anything(), TOKEN) + expect(removeCurrentUserFromConversation).toHaveBeenCalledWith(TOKEN) + expect(testStoreConfig.actions.deleteConversation).toHaveBeenCalledWith(expect.anything(), TOKEN) + }) }) - describe('participantsStore', () => { + describe('participant permissions', () => { beforeEach(() => { store.dispatch('addParticipant', { token: TOKEN,