diff --git a/src/App.vue b/src/App.vue index c69ecdd7857..942de6c2e24 100644 --- a/src/App.vue +++ b/src/App.vue @@ -68,6 +68,9 @@ import { signalingKill } from './utils/webrtc/index.js' /** Internal handlers for 'joined-conversation' watcher (voice-, breakout- rooms) */ let unwatchJoinedConversation = undefined let watchedJoinedConversationToken = undefined +/** + * Release the listener for joined conversation + */ function stopWatchingJoinedConversation() { unwatchJoinedConversation?.() unwatchJoinedConversation = undefined diff --git a/src/components/LeftSidebar/CallPhoneDialog/CallPhoneDialog.vue b/src/components/LeftSidebar/CallPhoneDialog/CallPhoneDialog.vue index 324ffb4a01c..a717fa60927 100644 --- a/src/components/LeftSidebar/CallPhoneDialog/CallPhoneDialog.vue +++ b/src/components/LeftSidebar/CallPhoneDialog/CallPhoneDialog.vue @@ -179,6 +179,7 @@ export default { flags, silent: false, recordingConsent: true, + options: { videoOn: false }, }) // request above could be cancelled, if there is parallel request, and return null diff --git a/src/components/RightSidebar/Participants/ParticipantItem.vue b/src/components/RightSidebar/Participants/ParticipantItem.vue index 68355c888be..85d4a3e74c2 100644 --- a/src/components/RightSidebar/Participants/ParticipantItem.vue +++ b/src/components/RightSidebar/Participants/ParticipantItem.vue @@ -947,6 +947,7 @@ export default { flags, silent: false, recordingConsent: true, + options: { videoOn: false }, }) } await callSIPDialOut(this.token, this.participant.attendeeId) diff --git a/src/components/SettingsDialog/SettingsDialog.vue b/src/components/SettingsDialog/SettingsDialog.vue index 711cf755cf6..8dfc60a8210 100644 --- a/src/components/SettingsDialog/SettingsDialog.vue +++ b/src/components/SettingsDialog/SettingsDialog.vue @@ -36,7 +36,7 @@ v-if="!isGuest" :modelValue="hideMediaSettings" :label="t('spreed', 'Skip device preview before joining a call')" - :description="t('spreed', 'Always shown if recording consent is required')" + :description="t('spreed', 'Camera will be turned off when joining. Always shown if recording consent is required.')" @update:modelValue="setHideMediaSettings" /> participant.actorType === ATTENDEE.ACTOR_TYPE.PHONES) - ?.attendeeId - if (attendeeId) { - this.dialOutPhoneNumber(attendeeId) - } - } }, async leaveCall(endMeetingForAll = false) { @@ -463,16 +429,14 @@ export default { this.soundsStore.initAudioObjects() if (this.isMediaSettings || this.isPhoneRoom) { - emit('talk:media-settings:hide') - this.joinCall() + this.handleJoinCall() return } if (this.showRecordingWarning || this.showMediaSettings) { emit('talk:media-settings:show') } else { - emit('talk:media-settings:hide') - this.joinCall() + this.handleJoinCall() } }, @@ -481,21 +445,6 @@ export default { token: this.breakoutRoomsStore.getParentRoomToken(this.token), }) }, - - async dialOutPhoneNumber(attendeeId) { - try { - await callSIPDialOut(this.token, attendeeId) - } catch (error) { - if (error?.response?.data?.ocs?.data?.message) { - showError(t('spreed', 'Phone number could not be called: {error}', { - error: error?.response?.data?.ocs?.data?.message, - })) - } else { - console.error(error) - showError(t('spreed', 'Phone number could not be called')) - } - } - }, }, } diff --git a/src/composables/useJoinCall.ts b/src/composables/useJoinCall.ts new file mode 100644 index 00000000000..5574fe9e67e --- /dev/null +++ b/src/composables/useJoinCall.ts @@ -0,0 +1,147 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Conversation, Participant } from '../types/index.ts' + +import { showError } from '@nextcloud/dialogs' +import { emit } from '@nextcloud/event-bus' +import { t } from '@nextcloud/l10n' +import { useStore } from 'vuex' +import { ATTENDEE, CALL, CONVERSATION, PARTICIPANT } from '../constants.ts' +import { callSIPDialOut } from '../services/callsService.ts' +import { getTalkConfig } from '../services/CapabilitiesManager.ts' +import { useActorStore } from '../stores/actor.ts' +import { useSettingsStore } from '../stores/settings.ts' +import { isAxiosErrorResponse } from '../types/guards.ts' + +/** + * Handler function to join a call and manage side effects + */ +export function useJoinCall() { + const actorStore = useActorStore() + const settingsStore = useSettingsStore() + const vuexStore = useStore() + + /** + * Returns whether the conversation is a phone room (with a single SIP phone participant) + * + * @param conversation - conversation object + * @param conversation.objectId - conversation objectId + * @param conversation.objectType - conversation objectType + */ + function isConversationPhoneRoom({ objectId, objectType }: Conversation) { + return objectId === CONVERSATION.OBJECT_ID.PHONE_OUTGOING + && [ + CONVERSATION.OBJECT_TYPE.PHONE_LEGACY, + CONVERSATION.OBJECT_TYPE.PHONE_PERSISTENT, + CONVERSATION.OBJECT_TYPE.PHONE_TEMPORARY, + ].includes(objectType) + } + + /** + * Tries to call the given SIP phone participant + * + * @param token - conversation token of where to join + * @param attendeeId - id of the phone participant + */ + async function dialOutPhoneNumber(token: string, attendeeId: number) { + try { + await callSIPDialOut(token, attendeeId) + } catch (exception) { + if (isAxiosErrorResponse<{ message: string }>(exception) && exception.response?.data?.ocs?.data?.message) { + showError(t('spreed', 'Phone number could not be called: {error}', { + error: exception.response.data.ocs.data.message, + })) + } else { + console.error(exception) + showError(t('spreed', 'Phone number could not be called')) + } + } + } + + /** + * Starts or joins a call + * + * @param token - conversation token of where to join + * @param options - joining options + * @param options.silent - whether to join the call silently (no notifications) + * @param options.recordingConsent - whether to join the call with recording consent + * @param options.shouldStartRecording - whether to start the recording together with the call (requires recording backend) + * @param options.directCall - whether to join call with video off + */ + async function joinCall(token: string, { + silent = false, + recordingConsent = false, + shouldStartRecording = false, + directCall = false, + } = {}) { + const conversation = vuexStore.getters.conversation(token) + if (!actorStore.participantIdentifier.sessionId || conversation.attendeeId !== actorStore.participantIdentifier.attendeeId) { + console.error('Trying to join call without having joined the conversation') + return + } + + const isPhoneRoom = isConversationPhoneRoom(conversation) + + // Define flags to join with (just call / with audio / with video) + let flags = PARTICIPANT.CALL_FLAG.IN_CALL + if (conversation.permissions & PARTICIPANT.PERMISSIONS.PUBLISH_AUDIO) { + flags |= PARTICIPANT.CALL_FLAG.WITH_AUDIO + } + if (conversation.permissions & PARTICIPANT.PERMISSIONS.PUBLISH_VIDEO && !isPhoneRoom) { + flags |= PARTICIPANT.CALL_FLAG.WITH_VIDEO + } + + // Close MediaSettings + emit('talk:media-settings:hide') + // Close navigation when joining the call + emit('toggle-navigation', { open: false }) + + const options: Partial> = {} + if (settingsStore.startWithoutMedia) { + // Option: 'Turn camera and microphone off by default' + options.audioOn = false + options.videoOn = false + } else if (!settingsStore.showMediaSettings) { + // Join calls with video off if device preview skipped + options.videoOn = false + } else if (directCall) { + // Join direct calls with video off + options.videoOn = false + } + + console.debug('Joining call') + await vuexStore.dispatch('joinCall', { + token, + participantIdentifier: actorStore.participantIdentifier, + flags, + silent, + recordingConsent, + options, + }) + + if (shouldStartRecording && getTalkConfig(token, 'call', 'recording')) { + // Do not wait for async operation + vuexStore.dispatch('startCallRecording', { + token, + callRecording: CALL.RECORDING.VIDEO, + }) + } + + if (isPhoneRoom) { + const attendeeId = vuexStore.getters.participantsList(token) + .find((participant: Participant) => participant.actorType === ATTENDEE.ACTOR_TYPE.PHONES) + ?.attendeeId + if (attendeeId) { + // Do not wait for async operation + dialOutPhoneNumber(token, attendeeId) + } + } + } + + return { + joinCall, + } +} diff --git a/src/services/callsService.ts b/src/services/callsService.ts index 62ae37e4d27..761f6971d81 100644 --- a/src/services/callsService.ts +++ b/src/services/callsService.ts @@ -35,10 +35,11 @@ import { * sound for other participants or not * @param recordingConsent Whether the participant gave their consent to be recorded * @param silentFor List of participants that should not receive a notification about the call + * @param options Additional options (for media) * @return The actual flags based on the available media */ -async function joinCall(token: string, flags: number, silent: boolean, recordingConsent: boolean, silentFor: string[]): Promise { - return signalingJoinCall(token, flags, silent, recordingConsent, silentFor) +async function joinCall(token: string, flags: number, silent: boolean, recordingConsent: boolean, silentFor: string[], options = {}): Promise { + return signalingJoinCall(token, flags, silent, recordingConsent, silentFor, options) } /** diff --git a/src/store/participantsStore.js b/src/store/participantsStore.js index 3a5d1424e9d..c8e45349b55 100644 --- a/src/store/participantsStore.js +++ b/src/store/participantsStore.js @@ -849,7 +849,7 @@ const actions = { return false }, - async joinCall({ commit, getters, state }, { token, participantIdentifier, flags, silent, recordingConsent, silentFor }) { + async joinCall({ commit, getters, state }, { token, participantIdentifier, flags, silent, recordingConsent, silentFor, options }) { // SUMMARY: join call process // There are 2 main steps to join a call: // 1. Join the call (signaling-join-call) @@ -945,7 +945,7 @@ const actions = { EventBus.on('signaling-users-changed', handleUsersChanged) try { - const actualFlags = await joinCall(token, flags, silent, recordingConsent, silentFor) + const actualFlags = await joinCall(token, flags, silent, recordingConsent, silentFor, options) const updatedData = { inCall: actualFlags, } diff --git a/src/store/participantsStore.spec.js b/src/store/participantsStore.spec.js index a3ed4d1139d..be14acf789c 100644 --- a/src/store/participantsStore.spec.js +++ b/src/store/participantsStore.spec.js @@ -601,7 +601,7 @@ describe('participantsStore', () => { }) const assertInitialCallState = () => { - expect(joinCall).toHaveBeenCalledWith(TOKEN, flags, false, false, undefined) + expect(joinCall).toHaveBeenCalledWith(TOKEN, flags, false, false, undefined, undefined) EventBus.emit('signaling-join-call', [TOKEN, actualFlags]) expect(store.getters.isInCall(TOKEN)).toBe(true) expect(store.getters.isConnecting(TOKEN)).toBe(true) diff --git a/src/types/vendor/@nextcloud/event-bus.d.ts b/src/types/vendor/@nextcloud/event-bus.d.ts index 65910f31064..43059e2565e 100644 --- a/src/types/vendor/@nextcloud/event-bus.d.ts +++ b/src/types/vendor/@nextcloud/event-bus.d.ts @@ -10,6 +10,14 @@ declare module '@nextcloud/event-bus' { 'user:info:changed': NextcloudUser 'notifications:action:execute': NotificationEvent 'notifications:notification:received': NotificationEvent + // LeftSidebar > NcAppNavigation + 'toggle-navigation': { open: boolean } + // MediaSettings + 'talk:media-settings:hide': void + 'talk:media-settings:show': void | 'video-verification' | 'device-check' | 'backgrounds' + // ConversationSettingsDialog + 'show-conversation-settings': { token: string } + 'hide-conversation-settings': void } } export {} diff --git a/src/utils/webrtc/index.js b/src/utils/webrtc/index.js index 04de2bcfca1..a9b4154c50d 100644 --- a/src/utils/webrtc/index.js +++ b/src/utils/webrtc/index.js @@ -233,10 +233,13 @@ async function signalingJoinConversation(token, sessionId) { * sound for other participants or not * @param {boolean} recordingConsent Whether the participant gave their consent to be recorded * @param {Array} silentFor List of participants that should not receive a notification about the call + * @param {object} options Additional options (for media) + * @param {boolean} [options.audioOn] Whether to enable audio on join + * @param {boolean} [options.videoOn] Whether to enable audio on join * @return {Promise} Resolved with the actual flags based on the * available media */ -async function signalingJoinCall(token, flags, silent, recordingConsent, silentFor) { +async function signalingJoinCall(token, flags, silent, recordingConsent, silentFor, options = {}) { if (tokensInSignaling[token]) { pendingJoinCallToken = token @@ -263,8 +266,8 @@ async function signalingJoinCall(token, flags, silent, recordingConsent, silentF // The previous state might be wiped after the media is started, so // it should be saved now. const noiseSuppressionWithModel = BrowserStorage.getItem('noiseSuppressionWithModel') === 'true' - const enableAudio = !BrowserStorage.getItem('audioDisabled_' + token) - const enableVideo = !BrowserStorage.getItem('videoDisabled_' + token) + const enableAudio = options?.audioOn ?? !BrowserStorage.getItem('audioDisabled_' + token) + const enableVideo = options?.videoOn ?? !BrowserStorage.getItem('videoDisabled_' + token) const enableVirtualBackground = !!BrowserStorage.getItem('virtualBackgroundEnabled') const virtualBackgroundType = BrowserStorage.getItem('virtualBackgroundType') const virtualBackgroundBlurStrength = BrowserStorage.getItem('virtualBackgroundBlurStrength') diff --git a/src/views/MainView.vue b/src/views/MainView.vue index 849846e293f..517bd068da0 100644 --- a/src/views/MainView.vue +++ b/src/views/MainView.vue @@ -3,8 +3,10 @@ - SPDX-License-Identifier: AGPL-3.0-or-later -->