Skip to content
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
70 changes: 69 additions & 1 deletion src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import { useCallViewStore } from './stores/callView.ts'
import { useSidebarStore } from './stores/sidebar.ts'
import { useTokenStore } from './stores/token.ts'
import { checkBrowser } from './utils/browserCheck.ts'
import { mountBrowseSignaling, unmountBrowseSignaling } from './utils/webrtc/browseSignaling.js'
import { signalingKill } from './utils/webrtc/index.js'

/** Internal handlers for 'joined-conversation' watcher (voice-, breakout- rooms) */
Expand Down Expand Up @@ -123,6 +124,9 @@ export default {
isRefreshingCurrentConversation: false,
skipLeaveWarning: false,
recordingConsentGiven: false,
// Token of the call kept alive in the background while browsing
// another conversation during a call, or null.
activeBrowseCallToken: null,
debounceRefreshCurrentConversation: () => {},
}
},
Expand Down Expand Up @@ -168,6 +172,21 @@ export default {
return !this.isLeavingAfterSessionIssue && this.isInCall
},

/**
* Whether the conversation held in the background during browsing (see
* `activeBrowseCallToken`) still has an ongoing call. Used to tear the
* secondary browse session down when the call ends remotely. Depends on
* the reactive `isInCall` getter for a real token, so it updates when
* the call ends.
*
* @return {boolean}
*/
browsedCallStillActive() {
return this.activeBrowseCallToken
? this.$store.getters.isInCall(this.activeBrowseCallToken)
: false
},

/**
* The current conversation
*
Expand Down Expand Up @@ -209,6 +228,21 @@ export default {
},
},

browsedCallStillActive(active) {
// The call ended (possibly remotely) while browsing another
// conversation: drop the secondary browse session and properly join
// the conversation that is now being viewed.
if (this.activeBrowseCallToken && !active) {
const endedCallToken = this.activeBrowseCallToken
this.activeBrowseCallToken = null
unmountBrowseSignaling()
if (this.token && this.token !== endedCallToken
&& !this.tokenStore.currentConversationIsJoined) {
this.$store.dispatch('joinConversation', { token: this.token })
}
}
},

unreadCountsMap: {
deep: true,
immediate: true,
Expand Down Expand Up @@ -325,6 +359,38 @@ export default {
return
}

// While a call is ongoing, keep its signaling session (and the call)
// alive and let the user browse other conversations without joining
// or leaving them. The browsed conversation is kept live-updated by a
// secondary lightweight signaling session (new messages, typing),
// while the call view is shown again when navigating back to the
// call's conversation.
// Read fresh on every navigation (not via a cached computed): the
// call token comes from non-reactive SessionStorage, so a computed
// would keep a stale value and the guard would not fire.
const joinedToken = SessionStorage.getItem('joined_conversation')
const activeCallToken = joinedToken && this.$store.getters.isInCall(joinedToken) ? joinedToken : null
if (activeCallToken && to.name === 'conversation' && from.params.token !== to.params.token) {
if (to.params.token === activeCallToken) {
// Returning to the call: drop the secondary browse session.
this.activeBrowseCallToken = null
unmountBrowseSignaling()
} else {
// Browsing another conversation during the call.
if (!this.$store.getters.conversation(to.params.token)) {
const result = await this.fetchSingleConversation(to.params.token)
if (!result) {
// If the conversation is not found, block further navigation
return
}
}
this.activeBrowseCallToken = activeCallToken
mountBrowseSignaling(to.params.token)
}
next()
return
}

if (from.name === 'conversation' && from.params.token !== to.params.token) {
// Await to properly close session / leave call before joining another one
await this.$store.dispatch('leaveConversation', { token: from.params.token })
Expand Down Expand Up @@ -377,9 +443,11 @@ export default {
if (from.name === 'conversation' && to.name === 'conversation' && from.params.token === to.params.token) {
// Navigating within the same conversation
beforeRouteChangeListener(to, from, next)
} else if (!this.warnLeaving || this.skipLeaveWarning || this.isVoiceRoom(from.params.token)) {
} else if (!this.warnLeaving || this.skipLeaveWarning || this.isVoiceRoom(from.params.token) || to.name === 'conversation') {
// Safe to navigate
// Note: voice rooms are intended to be left without confirmation.
// Note: navigating to another conversation during a call keeps the
// call alive (see beforeRouteChangeListener), so no warning is needed.
beforeRouteChangeListener(to, from, next)
} else {
spawnDialog(ConfirmDialog, {
Expand Down
18 changes: 16 additions & 2 deletions src/components/NewMessage/NewMessage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -544,7 +544,7 @@ export default {
},

disabled() {
return this.isReadOnly || this.noChatPermission || !this.currentConversationIsJoined || this.isRecordingAudio
return this.isReadOnly || this.noChatPermission || (!this.currentConversationIsJoined && !this.isBrowsingDuringCall) || this.isRecordingAudio
},

scheduleMessageTime() {
Expand All @@ -571,7 +571,7 @@ export default {
return t('spreed', 'This conversation has been locked')
} else if (this.noChatPermission) {
return t('spreed', 'No permission to post messages in this conversation')
} else if (!this.currentConversationIsJoined) {
} else if (!this.currentConversationIsJoined && !this.isBrowsingDuringCall) {
return t('spreed', 'Joining conversation …')
} else if (this.silentChat) {
return t('spreed', 'Write a message without notification')
Expand Down Expand Up @@ -638,6 +638,20 @@ export default {
return this.tokenStore.currentConversationIsJoined
},

/**
* Whether this conversation is being browsed while a call is ongoing in
* another conversation. In that case the conversation is not "joined" as
* the primary signaling session stays pinned to the call, but the user
* can still read and post messages (and a secondary signaling session
* keeps it live-updated), so the input must not be disabled.
*
* @return {boolean}
*/
isBrowsingDuringCall() {
const callToken = this.tokenStore.lastJoinedConversationToken
return Boolean(callToken) && callToken !== this.token && this.$store.getters.isInCall(callToken)
},

currentUploadId() {
return this.uploadStore.currentUploadId
},
Expand Down
48 changes: 48 additions & 0 deletions src/services/talkSessionUniqueTabId.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { afterEach, describe, expect, test } from 'vitest'
import {
getBrowseSessionRequestConfig,
getBrowseSessionTabId,
resetBrowseSessionTabId,
} from './talkSessionUniqueTabId.ts'

const HEADER = 'x-nextcloud-talk-session-tab-id'

describe('talkSessionUniqueTabId - browse session', () => {
afterEach(() => {
resetBrowseSessionTabId()
})

test('generates a stable browse tab id across calls', () => {
const first = getBrowseSessionTabId()
const second = getBrowseSessionTabId()
expect(first).toBe(second)
expect(first).toHaveLength(64)
})

test('rotates the browse tab id on reset', () => {
const before = getBrowseSessionTabId()
resetBrowseSessionTabId()
const after = getBrowseSessionTabId()
expect(after).not.toBe(before)
})

test('request config carries the browse tab id header', () => {
const config = getBrowseSessionRequestConfig()
expect((config.headers as Record<string, string>)[HEADER]).toBe(getBrowseSessionTabId())
})

test('request config merges provided config and headers', () => {
const config = getBrowseSessionRequestConfig({
params: { token: 'abc' },
headers: { 'X-Custom': '1' },
})
expect(config.params).toEqual({ token: 'abc' })
expect((config.headers as Record<string, string>)['X-Custom']).toBe('1')
expect((config.headers as Record<string, string>)[HEADER]).toBe(getBrowseSessionTabId())
})
})
51 changes: 50 additions & 1 deletion src/services/talkSessionUniqueTabId.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,56 @@ export function setTalkSessionUniqueTabIdHeader() {
}

axios.interceptors.request.use((config) => {
config.headers[X_NEXTCLOUD_TALK_SESSION_TAB_ID] = tabId
// Do not clobber an explicitly set tab id. A request targeting a
// secondary Talk session (see getBrowseSessionRequestConfig) sets its
// own id, which must reach the server unchanged so it is mapped to a
// separate session instead of the primary one.
if (!config.headers[X_NEXTCLOUD_TALK_SESSION_TAB_ID]) {
config.headers[X_NEXTCLOUD_TALK_SESSION_TAB_ID] = tabId
}
return config
})
}

/**
* Secondary, ephemeral tab id used to open a second Talk session from the same
* tab (e.g. to browse another conversation with live updates while a call is
* ongoing). Since https://github.com/nextcloud/spreed/pull/17230 the server
* maps requests to distinct sessions by this id, so a different id yields a
* second session without dropping the primary one.
*/
let browseSessionTabId: string | null = null

/**
* Get (creating it on first use) the secondary tab id for the browse session.
*/
export function getBrowseSessionTabId(): string {
if (!browseSessionTabId) {
browseSessionTabId = generateRandomId(64)
}
return browseSessionTabId
}

/**
* Rotate the secondary tab id, so the next browse session is a fresh one on the
* server. Call this after fully tearing down a browse session.
*/
export function resetBrowseSessionTabId(): void {
browseSessionTabId = null
}

/**
* Build an axios request config that routes the request through the secondary
* browse Talk session instead of the primary one.
*
* @param config additional axios request config to merge
*/
export function getBrowseSessionRequestConfig(config: Record<string, unknown> = {}): Record<string, unknown> {
return {
...config,
headers: {
...(config.headers as Record<string, string> ?? {}),
[X_NEXTCLOUD_TALK_SESSION_TAB_ID]: getBrowseSessionTabId(),
},
}
}
15 changes: 13 additions & 2 deletions src/utils/SignalingTypingHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,17 @@ SignalingTypingHandler.prototype = {
})
},

/**
* Token the received typing status has to be attributed to: the room the
* signaling connection is currently joined to. For the primary connection
* this is the viewed conversation (so behaviour is unchanged), but a
* secondary "browse" connection is joined to a different room than the one
* viewed, so relying on the viewed token would misattribute the status.
*/
_attributionToken() {
return this._signaling?.currentRoomToken ?? this._tokenStore.token
},

_handleMessage(data) {
if (data.type !== 'startedTyping' && data.type !== 'stoppedTyping') {
return
Expand All @@ -109,7 +120,7 @@ SignalingTypingHandler.prototype = {
}

this._participantActivityStore.setTyping({
token: this._tokenStore.token,
token: this._attributionToken(),
sessionId: participant.nextcloudSessionId,
isTyping: data.type === 'startedTyping',
})
Expand All @@ -131,7 +142,7 @@ SignalingTypingHandler.prototype = {
_handleParticipantsLeft(SignalingParticipantList, participants) {
for (const participant of participants) {
this._participantActivityStore.setTyping({
token: this._tokenStore.token,
token: this._attributionToken(),
sessionId: participant.nextcloudSessionId,
isTyping: false,
})
Expand Down
21 changes: 21 additions & 0 deletions src/utils/signaling.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,16 @@ Signaling.Base.prototype._trigger = function(ev, args) {
}
}

// A secondary "browse" signaling connection (used to keep receiving live
// updates for a conversation that is only being browsed while a call is
// ongoing in another conversation) must not leak its events onto the global
// EventBus, as those are consumed app-wide and would interfere with the
// primary connection that holds the call. Only the explicitly allow-listed
// events are forwarded; local per-instance handlers always run.
if (this._eventBusEmitAllowlist && !this._eventBusEmitAllowlist.has(ev)) {
return
}

// Convert webrtc event names to kebab-case for "vue/custom-event-name-casing"
const kebabCase = (string) => string
.replace(/([a-z])([A-Z])/g, '$1-$2')
Expand All @@ -124,6 +134,17 @@ Signaling.Base.prototype._trigger = function(ev, args) {
EventBus.emit('signaling-' + kebabCase(ev), args)
}

/**
* Restrict which events of this signaling instance are forwarded to the global
* EventBus. Pass an iterable of camelCase event names (as passed to _trigger),
* or null to forward everything (the default).
*
* @param {Iterable<string>|null} events allow-listed event names
*/
Signaling.Base.prototype.setEventBusEmitAllowlist = function(events) {
this._eventBusEmitAllowlist = events ? new Set(events) : null
}

Signaling.Base.prototype.setSettings = function(settings) {
if (!settings) {
// Signaling object is expected to always have a settings object
Expand Down
53 changes: 53 additions & 0 deletions src/utils/signalingEventBusAllowlist.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { afterEach, describe, expect, test, vi } from 'vitest'
import { EventBus } from '../services/EventBus.ts'
import Signaling from './signaling.js'

describe('Signaling._trigger EventBus allow-list', () => {
afterEach(() => {
vi.restoreAllMocks()
})

test('forwards every event to the EventBus by default', () => {
const base = new Signaling.Base()
const emitSpy = vi.spyOn(EventBus, 'emit')
const handler = vi.fn()
base.on('fooEvent', handler)

base._trigger('fooEvent', ['x'])

expect(handler).toHaveBeenCalledWith('x')
expect(emitSpy).toHaveBeenCalledWith('signaling-foo-event', ['x'])
})

test('with an allow-list, only listed events reach the EventBus but local handlers always run', () => {
const base = new Signaling.Base()
base.setEventBusEmitAllowlist(['supportedFeatures'])
const emitSpy = vi.spyOn(EventBus, 'emit')
const handler = vi.fn()
base.on('joinRoom', handler)

// Not allow-listed: local handler runs, nothing leaks to the EventBus.
base._trigger('joinRoom', ['token'])
expect(handler).toHaveBeenCalledWith('token')
expect(emitSpy).not.toHaveBeenCalledWith('signaling-join-room', ['token'])

// Allow-listed: forwarded to the EventBus.
base._trigger('supportedFeatures', [['chat-relay']])
expect(emitSpy).toHaveBeenCalledWith('signaling-supported-features', [['chat-relay']])
})

test('passing null restores forwarding of every event', () => {
const base = new Signaling.Base()
base.setEventBusEmitAllowlist(['supportedFeatures'])
base.setEventBusEmitAllowlist(null)
const emitSpy = vi.spyOn(EventBus, 'emit')

base._trigger('joinRoom', ['token'])
expect(emitSpy).toHaveBeenCalledWith('signaling-join-room', ['token'])
})
})
Loading