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
3 changes: 3 additions & 0 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -947,6 +947,7 @@ export default {
flags,
silent: false,
recordingConsent: true,
options: { videoOn: false },
})
}
await callSIPDialOut(this.token, this.participant.attendeeId)
Expand Down
2 changes: 1 addition & 1 deletion src/components/SettingsDialog/SettingsDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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" />
<NcFormBoxButton
:label="t('spreed', 'Microphone settings')"
Expand Down
67 changes: 8 additions & 59 deletions src/components/TopBar/CallButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ import IconPhoneOffOutline from 'vue-material-design-icons/PhoneOffOutline.vue'
import IconPhoneOutline from 'vue-material-design-icons/PhoneOutline.vue'
import { useGetToken } from '../../composables/useGetToken.ts'
import { useIsInCall } from '../../composables/useIsInCall.js'
import { useJoinCall } from '../../composables/useJoinCall.ts'
import { ATTENDEE, CALL, CONVERSATION, PARTICIPANT } from '../../constants.ts'
import { callSIPDialOut } from '../../services/callsService.ts'
import { getTalkConfig, hasTalkFeature } from '../../services/CapabilitiesManager.ts'
Expand Down Expand Up @@ -204,6 +205,7 @@ export default {
},

setup() {
const { joinCall } = useJoinCall()
return {
actorStore: useActorStore(),
tokenStore: useTokenStore(),
Expand All @@ -215,6 +217,7 @@ export default {
settingsStore: useSettingsStore(),
soundsStore: useSoundsStore(),
isMobile: useIsMobile(),
joinCall,
}
},

Expand Down Expand Up @@ -379,52 +382,15 @@ export default {

methods: {
t,
isParticipantTypeModerator(participantType) {
return [PARTICIPANT.TYPE.OWNER, PARTICIPANT.TYPE.MODERATOR, PARTICIPANT.TYPE.GUEST_MODERATOR].includes(participantType)
},

/**
* Starts or joins a call
*/
async joinCall() {
let flags = PARTICIPANT.CALL_FLAG.IN_CALL
if (this.conversation.permissions & PARTICIPANT.PERMISSIONS.PUBLISH_AUDIO) {
flags |= PARTICIPANT.CALL_FLAG.WITH_AUDIO
}
if (this.conversation.permissions & PARTICIPANT.PERMISSIONS.PUBLISH_VIDEO && !this.isPhoneRoom) {
flags |= PARTICIPANT.CALL_FLAG.WITH_VIDEO
}

console.info('Joining call')
async handleJoinCall() {
this.loading = true
// Close navigation
emit('toggle-navigation', {
open: false,
})
await this.$store.dispatch('joinCall', {
token: this.token,
participantIdentifier: this.actorStore.participantIdentifier,
flags,
await this.joinCall(this.token, {
silent: this.hasCall ? true : this.silentCall,
recordingConsent: this.recordingConsentGiven,
shouldStartRecording: this.isRecordingFromStart,
})
this.loading = false

if (this.isRecordingFromStart) {
this.$store.dispatch('startCallRecording', {
token: this.token,
callRecording: CALL.RECORDING.VIDEO,
})
}

if (this.isPhoneRoom) {
const attendeeId = this.$store.getters.participantsList(this.token)
.find((participant) => participant.actorType === ATTENDEE.ACTOR_TYPE.PHONES)
?.attendeeId
if (attendeeId) {
this.dialOutPhoneNumber(attendeeId)
}
}
},

async leaveCall(endMeetingForAll = false) {
Expand Down Expand Up @@ -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()
}
},

Expand All @@ -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'))
}
}
},
},
}
</script>
Expand Down
147 changes: 147 additions & 0 deletions src/composables/useJoinCall.ts
Original file line number Diff line number Diff line change
@@ -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<Record<'audioOn' | 'videoOn', boolean>> = {}
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,
}
}
5 changes: 3 additions & 2 deletions src/services/callsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
return signalingJoinCall(token, flags, silent, recordingConsent, silentFor)
async function joinCall(token: string, flags: number, silent: boolean, recordingConsent: boolean, silentFor: string[], options = {}): Promise<void> {
return signalingJoinCall(token, flags, silent, recordingConsent, silentFor, options)
}

/**
Expand Down
4 changes: 2 additions & 2 deletions src/store/participantsStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
}
Expand Down
2 changes: 1 addition & 1 deletion src/store/participantsStore.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions src/types/vendor/@nextcloud/event-bus.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
9 changes: 6 additions & 3 deletions src/utils/webrtc/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>} 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<void>} 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

Expand All @@ -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')
Expand Down
Loading
Loading