Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/__mocks__/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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'] },
}
2 changes: 2 additions & 0 deletions src/components/LeftSidebar/InvitationHandler.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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()
},
Expand Down
93 changes: 81 additions & 12 deletions src/services/CapabilitiesManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,27 +10,59 @@ 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<string, Capabilities & Partial<{ hash: string }>>
type RemoteCapability = Capabilities & Partial<{ hash: string }>
type RemoteCapabilities = Record<string, RemoteCapability>
type TokenMap = Record<string, string|undefined|null>

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
* @param feature feature capability in string format
*/
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)
}
}

Expand All @@ -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
}

/**
Expand All @@ -57,9 +109,10 @@ export function getTalkConfig(token: string = 'local', key1: keyof Config, key2:
*/
export async function setRemoteCapabilities(joinRoomResponse: JoinRoomFullResponse): Promise<void> {
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
}

Expand All @@ -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'), {
Expand All @@ -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
}
161 changes: 161 additions & 0 deletions src/services/__tests__/CapabilitiesManager.spec.js
Original file line number Diff line number Diff line change
@@ -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)
})
})
})