Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(capabilities): make a deep comparison #14328

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
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
14 changes: 14 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@
"@nextcloud/webpack-vue-config": "^6.2.0",
"@total-typescript/ts-reset": "^0.6.1",
"@types/jest": "^29.5.14",
"@types/lodash": "^4.17.15",
"@vue/test-utils": "^1.3.6",
"@vue/tsconfig": "^0.5.1",
"@vue/vue2-jest": "^29.2.6",
Expand Down
3 changes: 2 additions & 1 deletion src/components/TopBar/CallButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -353,8 +353,9 @@ export default {
},

watch: {
token() {
token(newValue, oldValue) {
this.callViewStore.resetCallHasJustEnded()
this.talkHashStore.resetTalkProxyHashDirty(oldValue)
}
},

Expand Down
95 changes: 81 additions & 14 deletions src/services/CapabilitiesManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,17 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { cloneDeep, isEqual } from 'lodash'

import { getCapabilities as _getCapabilities } from '@nextcloud/capabilities'
import { showError, TOAST_PERMANENT_TIMEOUT } from '@nextcloud/dialogs'
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, Conversation, JoinRoomFullResponse } from '../types/index.ts'
import { messagePleaseReload } from '../utils/talkDesktopUtils.ts'
import type { acceptShareResponse, Capabilities, Conversation, JoinRoomFullResponse } from '../types/index.ts'

type Config = Capabilities['spreed']['config']
type RemoteCapability = Capabilities & Partial<{ hash: string }>
type RemoteCapability = Capabilities & { hash?: string }
type RemoteCapabilities = Record<string, RemoteCapability>
type TokenMap = Record<string, string|undefined|null>

Expand Down Expand Up @@ -113,33 +112,101 @@ function getRemoteCapability(token: string): RemoteCapability | null {
* @param joinRoomResponse server response
*/
export async function setRemoteCapabilities(joinRoomResponse: JoinRoomFullResponse): Promise<void> {
const talkHashStore = useTalkHashStore()

const token = joinRoomResponse.data.ocs.data.token
const remoteServer = joinRoomResponse.data.ocs.data.remoteServer as string
const remoteServer = joinRoomResponse.data.ocs.data.remoteServer!

// Check if remote capabilities have not changed since last check
if (joinRoomResponse.headers['x-nextcloud-talk-proxy-hash'] === remoteCapabilities[remoteServer]?.hash) {
talkHashStore.resetTalkProxyHashDirty(token)
return
}

// Mark the hash as dirty to prevent any activity in the conversation
const talkHashStore = useTalkHashStore()
talkHashStore.setTalkProxyHashDirty(token)

const response = await getRemoteCapabilities(token)
if (!Object.keys(response.data.ocs.data).length) {
const newRemoteCapabilities = response.data.ocs.data as Capabilities['spreed']
if (!Object.keys(newRemoteCapabilities).length) {
// data: {} received from server, nothing to update with
return
}

remoteCapabilities[remoteServer] = { spreed: (response.data.ocs.data as Capabilities['spreed']) }
remoteCapabilities[remoteServer].hash = joinRoomResponse.headers['x-nextcloud-talk-proxy-hash']
const shouldShowWarning = checkRemoteCapabilitiesHasChanged(newRemoteCapabilities, remoteCapabilities[remoteServer]?.spreed)
remoteCapabilities[remoteServer] = {
spreed: newRemoteCapabilities,
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.') + '\n' + messagePleaseReload, {
timeout: TOAST_PERMANENT_TIMEOUT,
})
if (shouldShowWarning) {
// As normal capabilities update, requires a reload to take effect
talkHashStore.showTalkProxyHashDirtyToast()
} else {
talkHashStore.resetTalkProxyHashDirty(token)
}
}

/**
* Fetch new capabilities if remote server is not yet known
* @param acceptShareResponse server response
*/
export async function setRemoteCapabilitiesIfEmpty(acceptShareResponse: Awaited<acceptShareResponse>): Promise<void> {
const token = acceptShareResponse.data.ocs.data.token
const remoteServer = acceptShareResponse.data.ocs.data.remoteServer!

// Check if remote capabilities already exists
if (remoteCapabilities[remoteServer]) {
return
}

const response = await getRemoteCapabilities(token)
const newRemoteCapabilities = response.data.ocs.data as Capabilities['spreed']
if (!Object.keys(newRemoteCapabilities).length) {
// data: {} received from server, nothing to update with
return
}

remoteCapabilities[remoteServer] = { spreed: newRemoteCapabilities }
BrowserStorage.setItem('remoteCapabilities', JSON.stringify(remoteCapabilities))
patchTokenMap(acceptShareResponse.data.ocs.data)
}

/**
* Deep comparison of remote capabilities, whether there are actual changes that require reload
* @param newObject new remote capabilities
* @param oldObject old remote capabilities
*/
function checkRemoteCapabilitiesHasChanged(newObject: Capabilities['spreed'], oldObject: Capabilities['spreed']): boolean {
if (!newObject || !oldObject) {
return true
}

/**
* Returns remote config without local-only properties
* @param object remote capabilities object
*/
function getStrippedCapabilities(object: Capabilities['spreed']): { config: Partial<Config>, features: string[] } {
const config = cloneDeep(object.config)

for (const key1 of Object.keys(object['config-local']) as Array<keyof Config>) {
const keys2 = object['config-local'][key1]
for (const key2 of keys2 as Array<keyof Config[keyof Config]>) {
delete config[key1][key2]
}
if (!Object.keys(config[key1]).length) {
delete config[key1]
}
}

const features = object.features.filter(feature => !object['features-local'].includes(feature)).sort()

return { config, features }
}

return !isEqual(getStrippedCapabilities(newObject), getStrippedCapabilities(oldObject))
}

/**
Expand Down
89 changes: 88 additions & 1 deletion src/services/__tests__/CapabilitiesManager.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
hasTalkFeature,
getTalkConfig,
setRemoteCapabilities,
setRemoteCapabilitiesIfEmpty,
} from '../CapabilitiesManager.ts'
import { getRemoteCapabilities } from '../federationService.ts'

Expand Down Expand Up @@ -146,16 +147,102 @@ describe('CapabilitiesManager', () => {
expect(BrowserStorage.setItem).toHaveBeenCalledTimes(0)
})

it('should set capabilities for new server and mark talk proxy hash as dirty', async () => {
const token = 'TOKEN7FED3'
const remoteServer = 'https://nextcloud3.local'
const remoteHash = 'abc123'
const joinRoomResponseMock = generateOCSResponse({
headers: { 'x-nextcloud-talk-proxy-hash': remoteHash },
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)
})

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 })
const responseMock = generateOCSResponse({
payload: {
...mockedCapabilities.spreed,
features: [...mockedCapabilities.spreed.features, 'new-feature'],
}
})
getRemoteCapabilities.mockReturnValue(responseMock)
await setRemoteCapabilities(joinRoomResponseMock)
expect(talkHashStore.isNextcloudTalkProxyHashDirty[token]).toBeTruthy()
expect(BrowserStorage.setItem).toHaveBeenCalledTimes(1)
})

it('should reset dirty proxy hash after second fetch and negative check for changes', async () => {
const joinRoomResponseMock = generateOCSResponse({
headers: { 'x-nextcloud-talk-proxy-hash': `${remoteCapabilities.hash}003` },
payload: { token, remoteServer }
})
const joinRoomResponseMock2 = generateOCSResponse({
headers: { 'x-nextcloud-talk-proxy-hash': `${remoteCapabilities.hash}004` },
payload: { token, remoteServer }
})
const responseMock = generateOCSResponse({
payload: {
...mockedCapabilities.spreed,
features: [...mockedCapabilities.spreed.features, 'new-feature', 'new-feature-2'],
}
})
const responseMock2 = generateOCSResponse({
payload: {
...mockedCapabilities.spreed,
features: [...mockedCapabilities.spreed.features, 'new-feature', 'new-feature-2'],
}
})
getRemoteCapabilities.mockReturnValueOnce(responseMock).mockReturnValueOnce(responseMock2)
await setRemoteCapabilities(joinRoomResponseMock)
expect(talkHashStore.isNextcloudTalkProxyHashDirty[token]).toBeTruthy()
expect(BrowserStorage.setItem).toHaveBeenCalledTimes(1)

await setRemoteCapabilities(joinRoomResponseMock2)
expect(talkHashStore.isNextcloudTalkProxyHashDirty[token]).not.toBeDefined()
expect(BrowserStorage.setItem).toHaveBeenCalledTimes(2)
})
})

describe('setRemoteCapabilitiesIfEmpty', () => {
const token = 'TOKEN8FED4'
const remoteServer = 'https://nextcloud4.local'

it('should early return if already has capabilities', async () => {
const [remoteServer, remoteCapabilities] = Object.entries(mockedRemotes)[0]
const token = remoteCapabilities.tokens[0]
const acceptShareResponseMock = generateOCSResponse({
payload: { token, remoteServer },
})
await setRemoteCapabilitiesIfEmpty(acceptShareResponseMock)
expect(BrowserStorage.setItem).toHaveBeenCalledTimes(0)
})

it('should early return if no capabilities received from server', async () => {
const acceptShareResponseMock = generateOCSResponse({
payload: { token, remoteServer },
})
const responseMock = generateOCSResponse({ payload: {} })
getRemoteCapabilities.mockReturnValue(responseMock)
await setRemoteCapabilitiesIfEmpty(acceptShareResponseMock)
expect(BrowserStorage.setItem).toHaveBeenCalledTimes(0)
})

it('should set capabilities for new server', async () => {
const acceptShareResponseMock = generateOCSResponse({
payload: { token, remoteServer },
})
const responseMock = generateOCSResponse({ payload: mockedCapabilities.spreed })
getRemoteCapabilities.mockReturnValue(responseMock)
await setRemoteCapabilitiesIfEmpty(acceptShareResponseMock)
expect(BrowserStorage.setItem).toHaveBeenCalledTimes(1)
})
})
})
7 changes: 6 additions & 1 deletion src/stores/__tests__/federation.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@
*/
import { setActivePinia, createPinia } from 'pinia'

import { getShares, acceptShare, rejectShare } from '../../services/federationService.ts'
import { mockedCapabilities } from '../../__mocks__/capabilities.ts'
import { getShares, acceptShare, rejectShare, getRemoteCapabilities } from '../../services/federationService.ts'
import { generateOCSErrorResponse, generateOCSResponse } from '../../test-helpers.js'
import { useFederationStore } from '../federation.ts'

jest.mock('../../services/federationService', () => ({
getShares: jest.fn(),
acceptShare: jest.fn(),
rejectShare: jest.fn(),
getRemoteCapabilities: jest.fn(),
}))

describe('federationStore', () => {
Expand Down Expand Up @@ -187,6 +189,9 @@ describe('federationStore', () => {
const acceptResponse = generateOCSResponse({ payload: room })
acceptShare.mockResolvedValueOnce(acceptResponse)

const responseMock = generateOCSResponse({ payload: mockedCapabilities.spreed })
getRemoteCapabilities.mockReturnValue(responseMock)

// Act: accept invite
const conversation = await federationStore.acceptShare(invites[0].id)

Expand Down
3 changes: 3 additions & 0 deletions src/stores/federation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { t } from '@nextcloud/l10n'
import { getBaseUrl } from '@nextcloud/router'

import { FEDERATION } from '../constants.ts'
import { setRemoteCapabilitiesIfEmpty } from '../services/CapabilitiesManager.ts'
import { getShares, acceptShare, rejectShare } from '../services/federationService.ts'
import type { Conversation, FederationInvite, NotificationInvite } from '../types/index.ts'

Expand Down Expand Up @@ -109,6 +110,8 @@ export const useFederationStore = defineStore('federation', {
try {
Vue.set(this.pendingShares[id], 'loading', 'accept')
const response = await acceptShare(id)
// Fetch remote capabilities for unknown federation server
await setRemoteCapabilitiesIfEmpty(response)
this.markInvitationAccepted(id, response.data.ocs.data)
this.updatePendingSharesCount(Object.keys(this.pendingShares).length)
return response.data.ocs.data
Expand Down
24 changes: 24 additions & 0 deletions src/stores/talkHash.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export const useTalkHashStore = defineStore('talkHash', {
isNextcloudTalkHashDirty: false,
isNextcloudTalkProxyHashDirty: {},
maintenanceWarningToast: null,
proxyHashDirtyToast: null,
}),

actions: {
Expand Down Expand Up @@ -60,6 +61,20 @@ export const useTalkHashStore = defineStore('talkHash', {
Vue.set(this.isNextcloudTalkProxyHashDirty, token, true)
},

/**
* Clear the current Talk Federation dirty hash marker (as it is irrelevant after reload or leaving)
*
* @param {string} token federated conversation token
*/
resetTalkProxyHashDirty(token) {
Vue.delete(this.isNextcloudTalkProxyHashDirty, token)

if (this.proxyHashDirtyToast) {
this.proxyHashDirtyToast.hideToast()
this.proxyHashDirtyToast = null
}
},

/**
* Updates a Talk hash from a response
*
Expand Down Expand Up @@ -104,5 +119,14 @@ export const useTalkHashStore = defineStore('talkHash', {
this.maintenanceWarningToast = null
}
},

/**
* Show a toast message when Talk Federation requires rejoining
*/
showTalkProxyHashDirtyToast() {
this.proxyHashDirtyToast = showError(t('spreed', 'Nextcloud Talk Federation was updated.') + '\n' + messagePleaseReload, {
timeout: TOAST_PERMANENT_TIMEOUT,
})
},
}
})
Loading