From 362e1c670a163af3c7271b9d4c16188e564f4932 Mon Sep 17 00:00:00 2001 From: Vladimir Poluliashenko Date: Sat, 25 Jul 2026 11:33:19 +0100 Subject: [PATCH] feat(call): browse other conversations during a call via a second signaling session Keep the call's signaling + WebRTC singleton pinned to the call and open a separate lightweight signaling connection for the browsed conversation, so it stays live-updated (instant chat-relay messages, typing indicators) without a second window or a re-dial. The secondary session runs on a distinct Talk session (dedicated tab-id, applied via a non-clobbering request interceptor) and does not leak its events onto the global EventBus, so the call is never affected. It never joins a call or sets up WebRTC/media. The browsed conversation's participant list is loaded from the secondary session so that participant-dependent UI (typing indicator) works while browsing. Falls back to REST/polling browsing on the internal (non-HPB) signaling. Refs #12299 Signed-off-by: Vladimir Poluliashenko --- src/App.vue | 70 +++++- src/components/NewMessage/NewMessage.vue | 18 +- src/services/talkSessionUniqueTabId.spec.ts | 48 ++++ src/services/talkSessionUniqueTabId.ts | 51 +++- src/utils/SignalingTypingHandler.js | 15 +- src/utils/signaling.js | 21 ++ src/utils/signalingEventBusAllowlist.spec.js | 53 ++++ src/utils/webrtc/browseSignaling.js | 247 +++++++++++++++++++ 8 files changed, 517 insertions(+), 6 deletions(-) create mode 100644 src/services/talkSessionUniqueTabId.spec.ts create mode 100644 src/utils/signalingEventBusAllowlist.spec.js create mode 100644 src/utils/webrtc/browseSignaling.js diff --git a/src/App.vue b/src/App.vue index a440fb6c924..454590efe44 100644 --- a/src/App.vue +++ b/src/App.vue @@ -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) */ @@ -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: () => {}, } }, @@ -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 * @@ -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, @@ -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 }) @@ -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, { diff --git a/src/components/NewMessage/NewMessage.vue b/src/components/NewMessage/NewMessage.vue index 26eaa615002..7955fd2b09e 100644 --- a/src/components/NewMessage/NewMessage.vue +++ b/src/components/NewMessage/NewMessage.vue @@ -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() { @@ -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') @@ -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 }, diff --git a/src/services/talkSessionUniqueTabId.spec.ts b/src/services/talkSessionUniqueTabId.spec.ts new file mode 100644 index 00000000000..7a3235ccd2a --- /dev/null +++ b/src/services/talkSessionUniqueTabId.spec.ts @@ -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)[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)['X-Custom']).toBe('1') + expect((config.headers as Record)[HEADER]).toBe(getBrowseSessionTabId()) + }) +}) diff --git a/src/services/talkSessionUniqueTabId.ts b/src/services/talkSessionUniqueTabId.ts index 442259c6c4d..8ac3da82a55 100644 --- a/src/services/talkSessionUniqueTabId.ts +++ b/src/services/talkSessionUniqueTabId.ts @@ -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 = {}): Record { + return { + ...config, + headers: { + ...(config.headers as Record ?? {}), + [X_NEXTCLOUD_TALK_SESSION_TAB_ID]: getBrowseSessionTabId(), + }, + } +} diff --git a/src/utils/SignalingTypingHandler.js b/src/utils/SignalingTypingHandler.js index bbfd7c675c5..d2a65a51561 100644 --- a/src/utils/SignalingTypingHandler.js +++ b/src/utils/SignalingTypingHandler.js @@ -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 @@ -109,7 +120,7 @@ SignalingTypingHandler.prototype = { } this._participantActivityStore.setTyping({ - token: this._tokenStore.token, + token: this._attributionToken(), sessionId: participant.nextcloudSessionId, isTyping: data.type === 'startedTyping', }) @@ -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, }) diff --git a/src/utils/signaling.js b/src/utils/signaling.js index cbd039a4373..a2a4eba75a2 100644 --- a/src/utils/signaling.js +++ b/src/utils/signaling.js @@ -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') @@ -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|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 diff --git a/src/utils/signalingEventBusAllowlist.spec.js b/src/utils/signalingEventBusAllowlist.spec.js new file mode 100644 index 00000000000..c16f954a9eb --- /dev/null +++ b/src/utils/signalingEventBusAllowlist.spec.js @@ -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']) + }) +}) diff --git a/src/utils/webrtc/browseSignaling.js b/src/utils/webrtc/browseSignaling.js new file mode 100644 index 00000000000..5c930410756 --- /dev/null +++ b/src/utils/webrtc/browseSignaling.js @@ -0,0 +1,247 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import axios from '@nextcloud/axios' +import { generateOcsUrl } from '@nextcloud/router' +import { PRIVACY } from '../../constants.ts' +import { getTalkConfig } from '../../services/CapabilitiesManager.ts' +import { fetchSignalingSettings } from '../../services/signalingService.js' +import { getBrowseSessionRequestConfig, resetBrowseSessionTabId } from '../../services/talkSessionUniqueTabId.ts' +import store from '../../store/index.js' +import Signaling from '../signaling.js' +import SignalingTypingHandler from '../SignalingTypingHandler.js' + +/** + * Secondary, lightweight signaling connection used to keep a conversation + * live-updated (new messages via chat-relay, typing indicators) while it is + * only being *browsed* during a call that is held by the primary connection in + * another conversation. + * + * It intentionally never joins a call, never sets up WebRTC/media, and does not + * leak its events onto the global EventBus (only the chat-relay related ones, + * which are token-scoped, reach the app). The primary connection - and the + * call it holds - is therefore never touched. + * + * Only the High-Performance Backend (standalone signaling) supports this. With + * the internal signaling there is no cheap second connection, so callers should + * fall back to the REST/polling browsing behaviour. + */ + +const enableTypingIndicators = getTalkConfig('local', 'chat', 'typing-privacy') === PRIVACY.PUBLIC + +let browseSignaling = null +let browseTypingHandler = null +let browseToken = null +let participantsRefreshTimeout = null +/** Serializes mount/switch/unmount so overlapping navigations cannot race. */ +let pendingOperation = Promise.resolve() + +/** + * Token currently browsed with a live secondary session, or null. + * + * @return {string|null} + */ +function getBrowseSignalingToken() { + return browseToken +} + +/** + * REST-join the conversation on a *separate* Talk session (secondary tab id), + * so the primary session that holds the call is left untouched. + * + * @param {string} token conversation token + * @return {Promise} the Nextcloud session id, or null on failure + */ +async function restJoinBrowseSession(token) { + try { + const response = await axios.post( + generateOcsUrl('apps/spreed/api/v4/room/{token}/participants/active', { token }), + { force: false }, + getBrowseSessionRequestConfig(), + ) + return response.data.ocs.data.sessionId + } catch (error) { + console.error('[browseSignaling] Failed to open browse session for', token, error) + return null + } +} + +/** + * REST-leave the secondary Talk session. + * + * @param {string} token conversation token + */ +async function restLeaveBrowseSession(token) { + try { + await axios.delete( + generateOcsUrl('apps/spreed/api/v4/room/{token}/participants/active', { token }), + getBrowseSessionRequestConfig(), + ) + } catch (error) { + console.warn('[browseSignaling] Failed to close browse session for', token, error) + } +} + +/** + * Tear down the current secondary session, if any. + */ +async function teardown() { + clearTimeout(participantsRefreshTimeout) + participantsRefreshTimeout = null + + if (browseTypingHandler) { + browseTypingHandler.destroy() + browseTypingHandler = null + } + + const token = browseToken + if (browseSignaling) { + try { + await browseSignaling.leaveRoom(token) + } catch (error) { + console.warn('[browseSignaling] leaveRoom failed', error) + } + browseSignaling.disconnect() + browseSignaling = null + } + + if (token) { + await restLeaveBrowseSession(token) + } + + browseToken = null + resetBrowseSessionTabId() +} + +/** + * Bring up a secondary session for the given conversation. + * + * @param {string} token conversation token + * @return {Promise} true if a live session was established, false if + * the caller should fall back to REST/polling browsing + */ +async function setup(token) { + const settings = await fetchSignalingSettingsForBrowse(token) + if (!settings) { + return false + } + + if (settings.signalingMode === 'internal') { + // No High-Performance Backend: a second connection would be a second + // long-polling loop with no chat-relay. Fall back to REST/polling. + return false + } + + const sessionId = await restJoinBrowseSession(token) + if (!sessionId) { + return false + } + + browseSignaling = Signaling.createConnection(settings) + // Keep the secondary connection from interfering with the primary one: + // forward only the "supportedFeatures" event, which the chat needs to know + // that live chat-relay is available for the browsed conversation. + browseSignaling.setEventBusEmitAllowlist(['supportedFeatures']) + + if (enableTypingIndicators) { + browseTypingHandler = new SignalingTypingHandler() + browseTypingHandler.setSignaling(browseSignaling) + } + + // The browsed conversation is not joined the normal way, so the usual + // participant fetching (useGetParticipants) never runs for it. Load and keep + // its participant list fresh from the browse session's own room events, so + // participant-dependent UI works while browsing - notably the typing + // indicator, which matches typing signals against known participant sessions. + const refreshBrowseParticipants = () => { + if (browseToken !== token) { + return + } + clearTimeout(participantsRefreshTimeout) + participantsRefreshTimeout = setTimeout(() => { + if (browseToken === token) { + store.dispatch('fetchParticipants', { token }) + } + }, 500) + } + browseSignaling.on('usersInRoom', refreshBrowseParticipants) + browseSignaling.on('usersJoined', refreshBrowseParticipants) + browseSignaling.on('usersLeft', refreshBrowseParticipants) + + browseToken = token + + // joinRoom defers internally until the hello handshake completed. + browseSignaling.joinRoom(token, sessionId) + + // Initial participant load (covers participants already present before we + // joined, in case no incoming room event follows). + store.dispatch('fetchParticipants', { token }) + + return true +} + +/** + * Fetch signaling settings through the secondary Talk session. + * + * @param {string} token conversation token + * @return {Promise} + */ +async function fetchSignalingSettingsForBrowse(token) { + try { + const response = await fetchSignalingSettings({ token }, getBrowseSessionRequestConfig()) + const settings = response.data.ocs.data + settings.token = token + return settings + } catch (error) { + console.error('[browseSignaling] Failed to fetch signaling settings for', token, error) + return null + } +} + +/** + * Start (or switch to) a live secondary session for the browsed conversation. + * Safe to call repeatedly; only one secondary session exists at a time. + * + * @param {string} token conversation token to browse live + * @return {Promise} whether a live session is active for the token + */ +function mountBrowseSignaling(token) { + pendingOperation = pendingOperation.then(async () => { + if (browseToken === token && browseSignaling) { + return true + } + if (browseToken) { + await teardown() + } + return setup(token) + }).catch((error) => { + console.error('[browseSignaling] mount failed', error) + return false + }) + return pendingOperation +} + +/** + * Tear down the secondary session (call when returning to the call + * conversation or when the call ends). + * + * @return {Promise} + */ +function unmountBrowseSignaling() { + pendingOperation = pendingOperation.then(async () => { + if (browseToken) { + await teardown() + } + }).catch((error) => { + console.error('[browseSignaling] unmount failed', error) + }) + return pendingOperation +} + +export { + getBrowseSignalingToken, + mountBrowseSignaling, + unmountBrowseSignaling, +}