diff --git a/src/__mocks__/capabilities.ts b/src/__mocks__/capabilities.ts index d7048b03016..7a557e314f0 100644 --- a/src/__mocks__/capabilities.ts +++ b/src/__mocks__/capabilities.ts @@ -79,7 +79,10 @@ export const mockedCapabilities: Capabilities = { 'silent-send-state', 'chat-read-last', 'federation-v1', + 'federation-v2', 'ban-v1', + 'chat-reference-id', + 'mention-permissions', ], 'features-local': [ 'favorites', @@ -157,3 +160,8 @@ export const mockedCapabilities: Capabilities = { version: '20.0.0-dev.0', } } + +export const mockedRemotes = { + 'https://nextcloud1.local': { ...mockedCapabilities, hash: 'abc123', tokens: ['TOKEN3FED1'] }, + 'https://nextcloud2.local': { ...mockedCapabilities, hash: 'def123', tokens: ['TOKEN5FED2'] }, +} diff --git a/src/components/LeftSidebar/InvitationHandler.vue b/src/components/LeftSidebar/InvitationHandler.vue index 296482023fa..968a250234b 100644 --- a/src/components/LeftSidebar/InvitationHandler.vue +++ b/src/components/LeftSidebar/InvitationHandler.vue @@ -153,6 +153,8 @@ export default { const conversation = await this.federationStore.acceptShare(id) if (conversation?.token) { this.$store.dispatch('addConversation', conversation) + // TODO move cacheConversations to the store action + this.$store.dispatch('cacheConversations') } this.checkIfNoMoreInvitations() }, diff --git a/src/services/CapabilitiesManager.ts b/src/services/CapabilitiesManager.ts index 3324a607480..1813551b6e1 100644 --- a/src/services/CapabilitiesManager.ts +++ b/src/services/CapabilitiesManager.ts @@ -10,14 +10,45 @@ import { t } from '@nextcloud/l10n' import { getRemoteCapabilities } from './federationService.ts' import BrowserStorage from '../services/BrowserStorage.js' import { useTalkHashStore } from '../stores/talkHash.js' -import type { Capabilities, JoinRoomFullResponse } from '../types' +import type { Capabilities, Conversation, JoinRoomFullResponse } from '../types' type Config = Capabilities['spreed']['config'] -type RemoteCapabilities = Record> +type RemoteCapability = Capabilities & Partial<{ hash: string }> +type RemoteCapabilities = Record +type TokenMap = Record + +let remoteTokenMap: TokenMap = generateTokenMap() const localCapabilities: Capabilities = _getCapabilities() as Capabilities const remoteCapabilities: RemoteCapabilities = restoreRemoteCapabilities() +/** + * Generate new token map based on remoteCapabilities and cachedConversation + */ +function generateTokenMap() { + const tokenMap: TokenMap = {} + const storageValue = BrowserStorage.getItem('cachedConversations') + if (!storageValue?.length) { + return {} + } + const cachedConversations = JSON.parse(storageValue) as Conversation[] + cachedConversations.forEach(conversation => { + tokenMap[conversation.token] = conversation.remoteServer || null + }) + + return tokenMap +} + +/** + * Patch token map with new / updated remote conversation + * @param conversation conversation object from join response + */ +function patchTokenMap(conversation: Conversation) { + if (conversation.remoteServer) { + remoteTokenMap[conversation.token] = conversation.remoteServer + } +} + /** * Check whether the feature is presented (in case of federation - on both servers) * @param token conversation token @@ -25,12 +56,13 @@ const remoteCapabilities: RemoteCapabilities = restoreRemoteCapabilities() */ export function hasTalkFeature(token: string = 'local', feature: string): boolean { const hasLocalTalkFeature = localCapabilities?.spreed?.features?.includes(feature) ?? false + const remoteCapabilities = getRemoteCapability(token) if (localCapabilities?.spreed?.['features-local']?.includes(feature)) { return hasLocalTalkFeature - } else if (token === 'local' || !remoteCapabilities[token]) { + } else if (token === 'local' || !remoteCapabilities) { return hasLocalTalkFeature } else { - return hasLocalTalkFeature && (remoteCapabilities[token]?.spreed?.features?.includes(feature) ?? false) + return hasLocalTalkFeature && (remoteCapabilities?.spreed?.features?.includes(feature) ?? false) } } @@ -41,14 +73,34 @@ export function hasTalkFeature(token: string = 'local', feature: string): boolea * @param key2 second-level key (e.g. 'allowed') */ export function getTalkConfig(token: string = 'local', key1: keyof Config, key2: keyof Config[keyof Config]) { + const remoteCapabilities = getRemoteCapability(token) if (localCapabilities?.spreed?.['config-local']?.[key1]?.includes(key2)) { return localCapabilities?.spreed?.config?.[key1]?.[key2] - } else if (token === 'local' || !remoteCapabilities[token]) { + } else if (token === 'local' || !remoteCapabilities) { return localCapabilities?.spreed?.config?.[key1]?.[key2] } else { // TODO discuss handling remote config (respect remote only / both / minimal) - return remoteCapabilities[token]?.spreed?.config?.[key1]?.[key2] + return remoteCapabilities?.spreed?.config?.[key1]?.[key2] + } +} + +/** + * Returns capability for specified token (if already matches from one of remote servers) + * @param token token of the conversation + */ +function getRemoteCapability(token: string): RemoteCapability | null { + if (remoteTokenMap[token] === undefined) { + // Unknown conversation, attempt to get remoteServer from cached conversations + remoteTokenMap = generateTokenMap() } + + const remoteServer = remoteTokenMap[token] + if (!token || token === 'local' || !remoteServer) { + // Local or no conversation opened + return null + } + + return remoteCapabilities[remoteServer] ?? null } /** @@ -57,9 +109,10 @@ export function getTalkConfig(token: string = 'local', key1: keyof Config, key2: */ export async function setRemoteCapabilities(joinRoomResponse: JoinRoomFullResponse): Promise { const token = joinRoomResponse.data.ocs.data.token + const remoteServer = joinRoomResponse.data.ocs.data.remoteServer as string // Check if remote capabilities have not changed since last check - if (joinRoomResponse.headers['x-nextcloud-talk-proxy-hash'] === remoteCapabilities[token]?.hash) { + if (joinRoomResponse.headers['x-nextcloud-talk-proxy-hash'] === remoteCapabilities[remoteServer]?.hash) { return } @@ -73,9 +126,10 @@ export async function setRemoteCapabilities(joinRoomResponse: JoinRoomFullRespon return } - remoteCapabilities[token] = { spreed: response.data.ocs.data } - remoteCapabilities[token].hash = joinRoomResponse.headers['x-nextcloud-talk-proxy-hash'] + remoteCapabilities[remoteServer] = { spreed: response.data.ocs.data } + remoteCapabilities[remoteServer].hash = joinRoomResponse.headers['x-nextcloud-talk-proxy-hash'] BrowserStorage.setItem('remoteCapabilities', JSON.stringify(remoteCapabilities)) + patchTokenMap(joinRoomResponse.data.ocs.data) // As normal capabilities update, requires a reload to take effect showError(t('spreed', 'Nextcloud Talk Federation was updated, please reload the page'), { @@ -87,10 +141,25 @@ export async function setRemoteCapabilities(joinRoomResponse: JoinRoomFullRespon * Restores capabilities from BrowserStorage */ function restoreRemoteCapabilities(): RemoteCapabilities { - const remoteCapabilities = BrowserStorage.getItem('remoteCapabilities') - if (!remoteCapabilities?.length) { + const storageValue = BrowserStorage.getItem('remoteCapabilities') + if (!storageValue) { return {} } + const remoteCapabilities = JSON.parse(storageValue) as RemoteCapabilities + + // Migration step for capabilities based on token + let hasMigrated = false + Object.keys(remoteCapabilities).forEach(key => { + const remoteServer = remoteTokenMap[key] + if (remoteServer) { + remoteCapabilities[remoteServer] = remoteCapabilities[key] + delete remoteCapabilities[key] + hasMigrated = true + } + }) + if (hasMigrated) { + BrowserStorage.setItem('remoteCapabilities', JSON.stringify(remoteCapabilities)) + } - return JSON.parse(remoteCapabilities) as RemoteCapabilities + return remoteCapabilities } diff --git a/src/services/__tests__/CapabilitiesManager.spec.js b/src/services/__tests__/CapabilitiesManager.spec.js new file mode 100644 index 00000000000..9277b92bfbb --- /dev/null +++ b/src/services/__tests__/CapabilitiesManager.spec.js @@ -0,0 +1,161 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { createPinia, setActivePinia } from 'pinia' + +import { mockedCapabilities, mockedRemotes } from '../../__mocks__/capabilities.ts' +import { useTalkHashStore } from '../../stores/talkHash.js' +import { generateOCSResponse } from '../../test-helpers.js' +import BrowserStorage from '../BrowserStorage.js' +import { + hasTalkFeature, + getTalkConfig, + setRemoteCapabilities, +} from '../CapabilitiesManager.ts' +import { getRemoteCapabilities } from '../federationService.ts' + +jest.mock('../BrowserStorage', () => ({ + getItem: jest.fn(key => { + const mockedConversations = [ + { token: 'TOKEN1', remoteServer: undefined }, + { token: 'TOKEN2', remoteServer: undefined }, + { token: 'TOKEN3FED1', remoteServer: 'https://nextcloud1.local' }, + { token: 'TOKEN4FED1', remoteServer: 'https://nextcloud1.local' }, + { token: 'TOKEN5FED2', remoteServer: 'https://nextcloud2.local' }, + { token: 'TOKEN6FED2', remoteServer: 'https://nextcloud2.local' }, + ] + + if (key === 'remoteCapabilities') { + return JSON.stringify(mockedRemotes) + } else if (key === 'cachedConversations') { + return JSON.stringify(mockedConversations) + } + return null + }), + setItem: jest.fn(), + removeItem: jest.fn(), +})) + +jest.mock('../federationService', () => ({ + getRemoteCapabilities: jest.fn(), +})) + +describe('CapabilitiesManager', () => { + let talkHashStore + + beforeEach(() => { + setActivePinia(createPinia()) + talkHashStore = useTalkHashStore() + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + describe('hasTalkFeature - local conversation', () => { + it('should return false if the feature is not in the capabilities', () => { + expect(hasTalkFeature('TOKEN1', 'never-existed')).toBeFalsy() + }) + + it('should return true if the feature is in the capabilities', () => { + expect(hasTalkFeature('TOKEN1', 'federation-v1')).toBeTruthy() + }) + + it('should return true if the feature is in the local capabilities', () => { + expect(hasTalkFeature('local', 'favorites')).toBeTruthy() + }) + + it('should return true if the feature is in the features-local list', () => { + expect(hasTalkFeature('TOKEN1', 'favorites')).toBeTruthy() + }) + }) + + describe('hasTalkFeature - remote conversation', () => { + it('should return false if the feature is not in the capabilities', () => { + expect(hasTalkFeature('TOKEN3FED1', 'never-existed')).toBeFalsy() + }) + + it('should return true if the feature is in the capabilities', () => { + expect(hasTalkFeature('TOKEN3FED1', 'federation-v1')).toBeTruthy() + }) + }) + + describe('getTalkConfig - local conversation', () => { + it('should return false if the feature is not in the capabilities', () => { + expect(getTalkConfig('TOKEN1', 'never', 'existed')).toBeFalsy() + }) + + it('should return true if the feature is in the capabilities', () => { + expect(getTalkConfig('TOKEN1', 'call', 'enabled')).toBeTruthy() + }) + + it('should return true if the feature is in the local capabilities', () => { + expect(getTalkConfig('local', 'call', 'enabled')).toBeTruthy() + }) + + it('should return true if the feature is in the features-local list', () => { + expect(getTalkConfig('TOKEN1', 'attachments', 'allowed')).toBeTruthy() + }) + }) + + describe('getTalkConfig - remote conversation', () => { + it('should return false if the feature is not in the capabilities', () => { + expect(getTalkConfig('TOKEN3FED1', 'never', 'existed')).toBeFalsy() + }) + + it('should return true if the feature is in the capabilities', () => { + expect(getTalkConfig('TOKEN3FED1', 'call', 'enabled')).toBeTruthy() + }) + }) + + describe('getRemoteCapability', () => { + it('should return true for known remoteServer and unknown token capabilities', () => { + expect(hasTalkFeature('TOKEN4FED1', 'ban-v1')).toBeTruthy() + }) + it('should try to regenerate tokenMap for unknown token', () => { + hasTalkFeature('TOKEN7FED1', 'ban-v1') + expect(BrowserStorage.getItem).toHaveBeenCalledTimes(1) // retry once + expect(BrowserStorage.getItem).toHaveBeenCalledWith('cachedConversations') + }) + }) + + describe('setRemoteCapability', () => { + const [remoteServer, remoteCapabilities] = Object.entries(mockedRemotes)[0] + const token = remoteCapabilities.tokens[0] + + it('should early return if proxy hash unchanged', async () => { + const joinRoomResponseMock = generateOCSResponse({ + headers: { 'x-nextcloud-talk-proxy-hash': remoteCapabilities.hash }, + payload: { token, remoteServer }, + }) + await setRemoteCapabilities(joinRoomResponseMock) + expect(talkHashStore.isNextcloudTalkProxyHashDirty[token]).toBeUndefined() + expect(BrowserStorage.setItem).toHaveBeenCalledTimes(0) + }) + + it('should early return if no capabilities received from server', async () => { + const joinRoomResponseMock = generateOCSResponse({ + headers: { 'x-nextcloud-talk-proxy-hash': `${remoteCapabilities.hash}001` }, + payload: { token, remoteServer }, + }) + const responseMock = generateOCSResponse({ payload: [] }) + getRemoteCapabilities.mockReturnValue(responseMock) + await setRemoteCapabilities(joinRoomResponseMock) + expect(talkHashStore.isNextcloudTalkProxyHashDirty[token]).toBeTruthy() + expect(BrowserStorage.setItem).toHaveBeenCalledTimes(0) + }) + + it('should update capabilities from server response and mark talk proxy hash as dirty', async () => { + const joinRoomResponseMock = generateOCSResponse({ + headers: { 'x-nextcloud-talk-proxy-hash': `${remoteCapabilities.hash}002` }, + payload: { token, remoteServer } + }) + const responseMock = generateOCSResponse({ payload: mockedCapabilities.spreed }) + getRemoteCapabilities.mockReturnValue(responseMock) + await setRemoteCapabilities(joinRoomResponseMock) + expect(talkHashStore.isNextcloudTalkProxyHashDirty[token]).toBeTruthy() + expect(BrowserStorage.setItem).toHaveBeenCalledTimes(1) + }) + }) +})