diff --git a/src/App.vue b/src/App.vue index 2d1791814bf..084b1eb89c9 100644 --- a/src/App.vue +++ b/src/App.vue @@ -41,7 +41,6 @@ import debounce from 'debounce' import PreventUnload from 'vue-prevent-unload' import { getCurrentUser } from '@nextcloud/auth' -import axios from '@nextcloud/axios' import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus' import { generateUrl } from '@nextcloud/router' @@ -64,6 +63,7 @@ import Router from './router/router.js' import BrowserStorage from './services/BrowserStorage.js' import { EventBus } from './services/EventBus.js' import { leaveConversationSync } from './services/participantsService.js' +import { useFederationStore } from './stores/federation.js' import { checkBrowser } from './utils/browserCheck.js' import { signalingKill } from './utils/webrtc/index.js' @@ -87,6 +87,7 @@ export default { isMobile: useIsMobile(), isNextcloudTalkHashDirty: useHashCheck(), supportSessionState: useActiveSession(), + federationStore: useFederationStore(), } }, @@ -548,16 +549,24 @@ export default { // Federation invitation handling if (event.notification.objectType === 'remote_talk_share') { try { - const response = await axios.post(event.action.url) - this.$store.dispatch('addConversation', response.data.ocs.data) - this.$router.push({ - name: 'conversation', - params: { - token: response.data.ocs.data.token, - }, - }) - event.cancelAction = true + const conversation = await this.federationStore.acceptShare(event.notification.objectId) + if (conversation.token) { + this.$store.dispatch('addConversation', conversation) + this.$router.push({ name: 'conversation', params: { token: conversation.token } }) + } + } catch (error) { + console.error(error) + } + } + break + } + case 'DELETE': { + // Federation invitation handling + if (event.notification.objectType === 'remote_talk_share') { + try { + event.cancelAction = true + this.federationStore.rejectShare(event.notification.objectId) } catch (error) { console.error(error) } @@ -600,6 +609,7 @@ export default { } // Federation invitation handling case 'remote_talk_share': { + this.federationStore.addInvitationFromNotification(event.notification) break } default: break diff --git a/src/constants.js b/src/constants.js index 7255b16f9fe..109b3a0c6e3 100644 --- a/src/constants.js +++ b/src/constants.js @@ -267,3 +267,10 @@ export const AVATAR = { FULL: 512, }, } + +export const FEDERATION = { + STATE: { + PENDING: 0, + ACCEPTED: 1, + }, +} diff --git a/src/services/federationService.js b/src/services/federationService.js new file mode 100644 index 00000000000..71f92909bcb --- /dev/null +++ b/src/services/federationService.js @@ -0,0 +1,59 @@ +/** + * @copyright Copyright (c) 2024 Maksim Sukharev + * + * @author Maksim Sukharev + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +import axios from '@nextcloud/axios' +import { generateOcsUrl } from '@nextcloud/router' + +/** + * Fetches list of shares for a current user + * + * @param {object} [options] options; + */ +const getShares = async function(options) { + return axios.get(generateOcsUrl('apps/spreed/api/v1/federation/invitation', undefined, options), options) +} + +/** + * Accept an invitation by provided id. + * + * @param {number} id invitation id; + * @param {object} [options] options; + */ +const acceptShare = async function(id, options) { + return axios.post(generateOcsUrl('apps/spreed/api/v1/federation/invitation/{id}', { id }, options), {}, options) +} + +/** + * Reject an invitation by provided id. + * + * @param {number} id invitation id; + * @param {object} [options] options; + */ +const rejectShare = async function(id, options) { + return axios.delete(generateOcsUrl('apps/spreed/api/v1/federation/invitation/{id}', { id }, options), options) +} + +export { + getShares, + acceptShare, + rejectShare, +} diff --git a/src/stores/__tests__/federation.spec.js b/src/stores/__tests__/federation.spec.js new file mode 100644 index 00000000000..415732bf76b --- /dev/null +++ b/src/stores/__tests__/federation.spec.js @@ -0,0 +1,262 @@ +import { setActivePinia, createPinia } from 'pinia' + +import { getShares, acceptShare, rejectShare } from '../../services/federationService.js' +import { generateOCSErrorResponse, generateOCSResponse } from '../../test-helpers.js' +import { useFederationStore } from '../federation.js' + +jest.mock('../../services/federationService', () => ({ + getShares: jest.fn(), + acceptShare: jest.fn(), + rejectShare: jest.fn(), +})) + +describe('federationStore', () => { + const invites = [ + { + id: 2, + userId: 'user0', + state: 0, + localRoomId: 10, + accessToken: 'ACCESS_TOKEN', + remoteServerUrl: 'remote.nextcloud.com', + remoteToken: 'TOKEN_2', + remoteAttendeeId: 11, + inviterCloudId: 'user2@remote.nextcloud.com', + inviterDisplayName: 'User Two', + roomName: 'Federation room 2' + }, + { + id: 1, + userId: 'user0', + state: 1, + localRoomId: 9, + accessToken: 'ACCESS_TOKEN', + remoteServerUrl: 'remote.nextcloud.com', + remoteToken: 'TOKEN_1', + remoteAttendeeId: 11, + inviterCloudId: 'user1@remote.nextcloud.com', + inviterDisplayName: 'User One', + roomName: 'Federation room 1' + }, + ] + const notifications = [ + { + notificationId: 122, + app: 'spreed', + user: 'user0', + objectType: 'remote_talk_share', + objectId: '2', + messageRichParameters: { + user1: { + type: 'user', + id: 'user2', + name: 'User Two', + server: 'remote.nextcloud.com' + }, + roomName: { + type: 'highlight', + id: 'remote.nextcloud.com::TOKEN_2', + name: 'Federation room 2' + }, + }, + }, + { + notificationId: 123, + app: 'spreed', + user: 'user0', + objectType: 'remote_talk_share', + objectId: '3', + messageRichParameters: { + user1: { + type: 'user', + id: 'user3', + name: 'User Three', + server: 'remote.nextcloud.com' + }, + roomName: { + type: 'highlight', + id: 'remote.nextcloud.com::TOKEN_3', + name: 'Federation room 3' + }, + }, + } + ] + let federationStore + + beforeEach(async () => { + setActivePinia(createPinia()) + federationStore = useFederationStore() + }) + + afterEach(async () => { + jest.clearAllMocks() + }) + + it('returns an empty objects when invitations are not loaded yet', async () => { + // Assert: check initial state of the store + expect(federationStore.pendingShares).toStrictEqual({}) + expect(federationStore.acceptedShares).toStrictEqual({}) + }) + + it('does not handle accepted invitations when missing in the store', async () => { + // Act: accept invite from notification + await federationStore.markInvitationAccepted(invites[0].id, {}) + + // Assert: check initial state of the store + expect(federationStore.pendingShares).toStrictEqual({}) + expect(federationStore.acceptedShares).toStrictEqual({}) + }) + + it('processes a response from server and stores invites', async () => { + // Arrange + const response = generateOCSResponse({ payload: invites }) + getShares.mockResolvedValueOnce(response) + + // Act: load invites from server + await federationStore.getShares() + + // Assert + expect(getShares).toHaveBeenCalled() + expect(federationStore.pendingShares).toMatchObject({ [invites[0].id]: invites[0] }) + expect(federationStore.acceptedShares).toMatchObject({ [invites[1].id]: invites[1] }) + }) + + it('handles error in server request for getShares', async () => { + // Arrange + const response = generateOCSErrorResponse({ status: 404, payload: [] }) + getShares.mockRejectedValueOnce(response) + console.error = jest.fn() + + // Act + await federationStore.getShares() + + // Assert: store hasn't changed + expect(federationStore.pendingShares).toStrictEqual({}) + expect(federationStore.acceptedShares).toStrictEqual({}) + }) + + it('updates invites in the store after receiving a notification', async () => { + // Arrange + const response = generateOCSResponse({ payload: invites }) + getShares.mockResolvedValueOnce(response) + await federationStore.getShares() + + // Act: trigger notification handling + notifications.forEach(federationStore.addInvitationFromNotification) + + // Assert + expect(federationStore.pendingShares).toMatchObject({ + [invites[0].id]: invites[0], + [notifications[1].objectId]: { id: notifications[1].objectId }, + }) + expect(federationStore.acceptedShares).toMatchObject({ [invites[1].id]: invites[1] }) + }) + + it('accepts invitation and modify store', async () => { + // Arrange + const response = generateOCSResponse({ payload: invites }) + getShares.mockResolvedValueOnce(response) + await federationStore.getShares() + + const room = { + id: 10, + remoteAccessToken: 'ACCESS_TOKEN', + } + const acceptResponse = generateOCSResponse({ payload: room }) + acceptShare.mockResolvedValueOnce(acceptResponse) + + // Act: accept invite + const conversation = await federationStore.acceptShare(invites[0].id) + + // Assert + expect(acceptShare).toHaveBeenCalledWith(invites[0].id) + expect(conversation).toMatchObject(room) + expect(federationStore.pendingShares).toStrictEqual({}) + expect(federationStore.acceptedShares).toMatchObject({ + [invites[0].id]: { ...invites[0], state: 1 }, + [invites[1].id]: invites[1], + }) + }) + + it('skip already accepted invitations', async () => { + // Arrange + const response = generateOCSResponse({ payload: invites }) + getShares.mockResolvedValueOnce(response) + await federationStore.getShares() + + // Act: accept invite + await federationStore.acceptShare(invites[1].id) + + // Assert + expect(acceptShare).not.toHaveBeenCalled() + expect(federationStore.pendingShares).toMatchObject({ [invites[0].id]: invites[0] }) + expect(federationStore.acceptedShares).toMatchObject({ [invites[1].id]: invites[1] }) + }) + + it('handles error in server request for acceptShare', async () => { + // Arrange + const response = generateOCSResponse({ payload: invites }) + getShares.mockResolvedValueOnce(response) + await federationStore.getShares() + const errorResponse = generateOCSErrorResponse({ status: 404, payload: [] }) + acceptShare.mockRejectedValueOnce(errorResponse) + console.error = jest.fn() + + // Act + await federationStore.acceptShare(invites[0].id) + + // Assert: store hasn't changed + expect(federationStore.pendingShares).toMatchObject({ [invites[0].id]: invites[0] }) + expect(federationStore.acceptedShares).toMatchObject({ [invites[1].id]: invites[1] }) + }) + + it('rejects invitation and modify store', async () => { + // Arrange + const response = generateOCSResponse({ payload: invites }) + getShares.mockResolvedValueOnce(response) + await federationStore.getShares() + + const rejectResponse = generateOCSResponse({ payload: [] }) + rejectShare.mockResolvedValueOnce(rejectResponse) + + // Act: reject invite + await federationStore.rejectShare(invites[0].id) + + // Assert + expect(rejectShare).toHaveBeenCalledWith(invites[0].id) + expect(federationStore.pendingShares).toStrictEqual({}) + expect(federationStore.acceptedShares).toMatchObject({ [invites[1].id]: invites[1] }) + }) + + it('skip already rejected invitations', async () => { + // Arrange + const response = generateOCSResponse({ payload: invites }) + getShares.mockResolvedValueOnce(response) + await federationStore.getShares() + + // Act: reject invite + await federationStore.rejectShare(invites[1].id) + + // Assert + expect(rejectShare).not.toHaveBeenCalled() + expect(federationStore.pendingShares).toMatchObject({ [invites[0].id]: invites[0] }) + expect(federationStore.acceptedShares).toMatchObject({ [invites[1].id]: invites[1] }) + }) + + it('handles error in server request for rejectShare', async () => { + // Arrange + const response = generateOCSResponse({ payload: invites }) + getShares.mockResolvedValueOnce(response) + await federationStore.getShares() + const errorResponse = generateOCSErrorResponse({ status: 404, payload: [] }) + rejectShare.mockRejectedValueOnce(errorResponse) + console.error = jest.fn() + + // Act + await federationStore.rejectShare(invites[0].id) + + // Assert: store hasn't changed + expect(federationStore.pendingShares).toMatchObject({ [invites[0].id]: invites[0] }) + expect(federationStore.acceptedShares).toMatchObject({ [invites[1].id]: invites[1] }) + }) +}) diff --git a/src/stores/federation.js b/src/stores/federation.js new file mode 100644 index 00000000000..5761354b364 --- /dev/null +++ b/src/stores/federation.js @@ -0,0 +1,181 @@ +/** + * @copyright Copyright (c) 2024 Maksim Sukharev + * + * @author Maksim Sukharev + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +import { defineStore } from 'pinia' +import Vue from 'vue' + +import { FEDERATION } from '../constants.js' +import { getShares, acceptShare, rejectShare } from '../services/federationService.js' + +/** + * @typedef {object} Share + * @property {string} accessToken the invitation access token + * @property {number} id the invitation id + * @property {number} localRoomId the invitation local room id + * @property {number} remoteAttendeeId the invitation remote attendee id + * @property {string} remoteServerUrl the invitation remote server URL + * @property {string} remoteToken the invitation remote token + * @property {string} roomName the invitation room name + * @property {number} state the invitation state + * @property {string} userId the invitation user id + * @property {string} inviterCloudId the inviter cloud id + * @property {string} inviterDisplayName the inviter display name + */ + +/** + * @typedef {object} State + * @property {{[key: string]: Share}} pendingShares - pending invitations + * @property {{[key: string]: Share}} acceptedShares - accepted invitations + */ + +/** + * Store for other app integrations (additional actions for messages, participants, e.t.c) + * + * @param {string} id store name + * @param {State} options.state store state structure + */ +export const useFederationStore = defineStore('federation', { + state: () => ({ + pendingShares: {}, + acceptedShares: {}, + }), + + actions: { + /** + * Fetch pending invitations and keep them in store + * + */ + async getShares() { + try { + const response = await getShares() + response.data.ocs.data + .forEach(item => { + if (item.state === FEDERATION.STATE.ACCEPTED) { + Vue.set(this.acceptedShares, item.id, item) + } else { + Vue.set(this.pendingShares, item.id, item) + } + }) + } catch (error) { + console.error(error) + } + }, + + /** + * Add an invitation from notification to the store. + * + * @param {object} notification notification object + */ + addInvitationFromNotification(notification) { + if (this.pendingShares[notification.objectId]) { + return + } + const [remoteServerUrl, remoteToken] = notification.messageRichParameters.roomName.id.split('::') + const { id, name } = notification.messageRichParameters.user1 + const invitation = { + accessToken: null, + id: notification.objectId, + localRoomId: null, + remoteAttendeeId: null, + remoteServerUrl, + remoteToken, + roomName: notification.messageRichParameters.roomName.name, + state: FEDERATION.STATE.PENDING, + userId: notification.user, + inviterCloudId: id + '@' + remoteServerUrl, + inviterDisplayName: name, + } + Vue.set(this.pendingShares, invitation.id, invitation) + }, + + /** + * Mark an invitation as loading in store. + * + * @param {number} id invitation id + * @param {boolean} value loading state + */ + markInvitationLoading(id, value) { + Vue.set(this.pendingShares[id], 'loading', value) + }, + + /** + * Mark an invitation as accepted in store. + * + * @param {number} id invitation id + * @param {object} conversation conversation object + */ + markInvitationAccepted(id, conversation) { + if (!this.pendingShares[id]) { + return + } + Vue.delete(this.pendingShares[id], 'loading') + Vue.set(this.acceptedShares, id, { + ...this.pendingShares[id], + accessToken: conversation.remoteAccessToken, + localRoomId: conversation.id, + state: FEDERATION.STATE.ACCEPTED, + }) + Vue.delete(this.pendingShares, id) + }, + + /** + * Accept an invitation by provided id. + * + * @param {number} id invitation id + * @return {object} conversation to join + */ + async acceptShare(id) { + if (!this.pendingShares[id]) { + return + } + this.markInvitationLoading(id, true) + try { + const response = await acceptShare(id) + this.markInvitationLoading(id, false) + this.markInvitationAccepted(id, response.data.ocs.data) + return response.data.ocs.data + } catch (error) { + console.error(error) + this.markInvitationLoading(id, false) + } + }, + + /** + * Reject an invitation by provided id. + * + * @param {number} id invitation id + */ + async rejectShare(id) { + if (!this.pendingShares[id]) { + return + } + this.markInvitationLoading(id, true) + try { + await rejectShare(id) + Vue.delete(this.pendingShares, id) + } catch (error) { + console.error(error) + this.markInvitationLoading(id, false) + } + }, + }, +})