diff --git a/REUSE.toml b/REUSE.toml index 356522f0f7f..4df63b562e6 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -66,7 +66,7 @@ SPDX-FileCopyrightText = "Nextcloud GmbH " SPDX-License-Identifier = "LicenseRef-NextcloudTrademarks" [[annotations]] -path = ["img/bridge-bot.png", "img/bridge-services/irc.svg", "img/bridge-services/msteams.svg", "img/bridge-services/slack.svg", "img/bridge-services/steam.svg", "img/bridge-services/xmpp.svg", "img/icon-contacts-white.svg", "img/icon-conversation-event-bright.svg", "img/icon-conversation-event-dark.svg", "img/icon-conversation-federation-bright.svg", "img/icon-conversation-federation-dark.svg", "img/icon-conversation-group-bright.svg", "img/icon-conversation-group-dark.svg", "img/icon-conversation-mail-bright.svg", "img/icon-conversation-mail-dark.svg", "img/icon-conversation-password-bright.svg", "img/icon-conversation-password-dark.svg", "img/icon-conversation-phone-bright.svg", "img/icon-conversation-phone-dark.svg", "img/icon-conversation-public-bright.svg", "img/icon-conversation-public-dark.svg", "img/icon-conversation-text-bright.svg", "img/icon-conversation-text-dark.svg", "img/icon-conversation-user-bright.svg", "img/icon-conversation-user-dark.svg", "img/icon-event-white.svg", "img/icon-mail-white.svg", "img/icon-password-white.svg", "img/icon-phone-white.svg", "img/icon-public-white.svg", "img/icon-team-white.svg", "img/icon-text-white.svg", "img/icon-user-white.svg", "img/phone.png", "img/material-icons/*.svg"] +path = ["img/bridge-bot.png", "img/bridge-services/irc.svg", "img/bridge-services/msteams.svg", "img/bridge-services/slack.svg", "img/bridge-services/steam.svg", "img/bridge-services/xmpp.svg", "img/icon-contacts-white.svg", "img/icon-conversation-event-bright.svg", "img/icon-conversation-event-dark.svg", "img/icon-conversation-federation-bright.svg", "img/icon-conversation-federation-dark.svg", "img/icon-conversation-group-bright.svg", "img/icon-conversation-group-dark.svg", "img/icon-conversation-mail-bright.svg", "img/icon-conversation-mail-dark.svg", "img/icon-conversation-password-bright.svg", "img/icon-conversation-password-dark.svg", "img/icon-conversation-phone-bright.svg", "img/icon-conversation-phone-dark.svg", "img/icon-conversation-public-bright.svg", "img/icon-conversation-public-dark.svg", "img/icon-conversation-text-bright.svg", "img/icon-conversation-text-dark.svg", "img/icon-conversation-user-bright.svg", "img/icon-conversation-user-dark.svg", "img/icon-event-white.svg", "img/icon-mail-white.svg", "img/icon-password-white.svg", "img/icon-phone-white.svg", "img/icon-public-white.svg", "img/icon-team-white.svg", "img/icon-text-white.svg", "img/icon-user-white.svg", "img/icon-conversation-voice-room-bright.svg", "img/icon-conversation-voice-room-dark.svg", "img/phone.png", "img/material-icons/*.svg"] precedence = "aggregate" SPDX-FileCopyrightText = "2018-2025 Google LLC" SPDX-License-Identifier = "Apache-2.0" diff --git a/img/icon-conversation-voice-room-bright.svg b/img/icon-conversation-voice-room-bright.svg new file mode 100644 index 00000000000..de6dfd18e0c --- /dev/null +++ b/img/icon-conversation-voice-room-bright.svg @@ -0,0 +1,8 @@ + + + + + + diff --git a/img/icon-conversation-voice-room-dark.svg b/img/icon-conversation-voice-room-dark.svg new file mode 100644 index 00000000000..3ec12f410bc --- /dev/null +++ b/img/icon-conversation-voice-room-dark.svg @@ -0,0 +1,8 @@ + + + + + + diff --git a/lib/RoomPresets/DefaultPreset.php b/lib/RoomPresets/DefaultPreset.php index 51977569b9e..ec9b34c8845 100644 --- a/lib/RoomPresets/DefaultPreset.php +++ b/lib/RoomPresets/DefaultPreset.php @@ -13,15 +13,27 @@ use OCA\Talk\Service\RecordingService; use OCA\Talk\Webinary; use OCP\AppFramework\Services\IAppConfig; +use OCP\IL10N; readonly class DefaultPreset extends APreset { public const CONFIG_PREFIX_DEFAULT = 'default_'; public function __construct( protected IAppConfig $appConfig, + protected IL10N $l, ) { } + #[\Override] + public function getName(): string { + return $this->l->t('Default'); + } + + #[\Override] + public function getDescription(): string { + return $this->l->t('Send messages, create threads, and start voice and video calls.'); + } + #[\Override] public static function getIdentifier(): string { return 'default'; diff --git a/lib/RoomPresets/VoiceRoom.php b/lib/RoomPresets/VoiceRoom.php index 1c8d401ff1f..85fa7938332 100644 --- a/lib/RoomPresets/VoiceRoom.php +++ b/lib/RoomPresets/VoiceRoom.php @@ -29,7 +29,7 @@ public function getName(): string { #[\Override] public function getDescription(): string { - return $this->l->t('Voice room'); + return $this->l->t('Directly join the call, ideal for catch-ups or spontaneous meetings.'); } #[\Override] diff --git a/lib/Service/AvatarService.php b/lib/Service/AvatarService.php index 50afaf954d3..530ca0d709b 100644 --- a/lib/Service/AvatarService.php +++ b/lib/Service/AvatarService.php @@ -11,6 +11,7 @@ use InvalidArgumentException; use OCA\Talk\Room; +use OCA\Talk\RoomAttributes; use OCP\Files\IAppData; use OCP\Files\IFilenameValidator; use OCP\Files\NotFoundException; @@ -273,6 +274,9 @@ private function getAvatarPath(Room $room, bool $darkTheme = false): string { if ($room->isFederatedConversation()) { return __DIR__ . '/../../img/icon-conversation-federation-' . $colorTone . '.svg'; } + if (($room->getAttributes() & RoomAttributes::VOICE_ROOM->value) === RoomAttributes::VOICE_ROOM->value) { + return __DIR__ . '/../../img/icon-conversation-voice-room-' . $colorTone . '.svg'; + } if ($room->getType() === Room::TYPE_PUBLIC) { return __DIR__ . '/../../img/icon-conversation-public-' . $colorTone . '.svg'; } diff --git a/src/App.vue b/src/App.vue index e1f08ea14be..e4c84c82574 100644 --- a/src/App.vue +++ b/src/App.vue @@ -56,6 +56,7 @@ import { CONVERSATION, PARTICIPANT } from './constants.ts' import BrowserStorage from './services/BrowserStorage.js' import { EventBus } from './services/EventBus.ts' import { leaveConversationSync } from './services/participantsService.js' +import SessionStorage from './services/SessionStorage.js' import { useActorStore } from './stores/actor.ts' import { useCallViewStore } from './stores/callView.ts' import { useSidebarStore } from './stores/sidebar.ts' @@ -159,6 +160,14 @@ export default { currentConversation() { return this.$store.getters.conversation(this.token) }, + + isVoiceRoom() { + return Boolean(this.currentConversation?.attributes & CONVERSATION.ATTRIBUTE.VOICE_ROOM) + }, + + voiceRoomIdentifier() { + return [this.token, this.isVoiceRoom] + }, }, watch: { @@ -176,6 +185,30 @@ export default { } }, + voiceRoomIdentifier: { + immediate: true, + handler(newValue, oldValue = []) { + const [newToken, newIsVoiceRoom] = newValue + const [oldToken, oldIsVoiceRoom] = oldValue + + if (!newIsVoiceRoom && oldIsVoiceRoom && newToken !== oldToken) { + this.callViewStore.setForceCallView(false) + } + + if (oldIsVoiceRoom && newToken !== oldToken) { + this.callViewStore.setSelectedVideoPeerId(null) + this.$store.dispatch('leaveCall', { + token: oldToken, + participantIdentifier: this.actorStore.participantIdentifier, + }) + } + + if (newIsVoiceRoom && newToken) { + this.joinCallAutomatically() + } + }, + }, + isInCall: { immediate: true, handler(value) { @@ -264,93 +297,7 @@ export default { }) EventBus.on('switch-to-conversation', async (params) => { - if (this.isInCall) { - this.callViewStore.setForceCallView(true) - - const enableAudio = !BrowserStorage.getItem('audioDisabled_' + this.token) - const enableVideo = !BrowserStorage.getItem('videoDisabled_' + this.token) - const enableVirtualBackground = !!BrowserStorage.getItem('virtualBackgroundEnabled') - const virtualBackgroundType = BrowserStorage.getItem('virtualBackgroundType') - const virtualBackgroundBlurStrength = BrowserStorage.getItem('virtualBackgroundBlurStrength') - const virtualBackgroundUrl = BrowserStorage.getItem('virtualBackgroundUrl') - - // Fetch conversation object, if it's not known yet to the client - if (!this.$store.getters.conversation(params.token)) { - await this.fetchSingleConversation(params.token) - } - - const conversation = this.$store.getters.conversation(this.token) - const previousParticipants = [] - if (conversation.type === CONVERSATION.TYPE.ONE_TO_ONE) { - previousParticipants.push(conversation.name) - } - - EventBus.once('joined-conversation', async ({ token }) => { - if (params.token !== token) { - return - } - - if (enableAudio) { - BrowserStorage.removeItem('audioDisabled_' + token) - } else { - BrowserStorage.setItem('audioDisabled_' + token, 'true') - } - if (enableVideo) { - BrowserStorage.removeItem('videoDisabled_' + token) - } else { - BrowserStorage.setItem('videoDisabled_' + token, 'true') - } - if (enableVirtualBackground) { - BrowserStorage.setItem('virtualBackgroundEnabled', 'true') - } else { - BrowserStorage.removeItem('virtualBackgroundEnabled') - } - if (virtualBackgroundType) { - BrowserStorage.setItem('virtualBackgroundType', virtualBackgroundType) - } else { - BrowserStorage.removeItem('virtualBackgroundType') - } - if (virtualBackgroundBlurStrength) { - BrowserStorage.setItem('virtualBackgroundBlurStrength', virtualBackgroundBlurStrength) - } else { - BrowserStorage.removeItem('virtualBackgroundBlurStrength') - } - if (virtualBackgroundUrl) { - BrowserStorage.setItem('virtualBackgroundUrl', virtualBackgroundUrl) - } else { - BrowserStorage.removeItem('virtualBackgroundUrl') - } - - const conversation = this.$store.getters.conversation(token) - - 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) { - flags |= PARTICIPANT.CALL_FLAG.WITH_VIDEO - } - - const payload = { - token: params.token, - participantIdentifier: this.actorStore.participantIdentifier, - flags, - silent: true, - recordingConsent: this.recordingConsentGiven, - } - - if (conversation.objectType === CONVERSATION.OBJECT_TYPE.EXTENDED) { - payload.silent = false - if (previousParticipants.length) { - payload.silentFor = previousParticipants - } - } - - await this.$store.dispatch('joinCall', payload) - - this.callViewStore.setForceCallView(false) - }) - } + this.joinCallAutomatically() this.skipLeaveWarning = true this.$router.push({ name: 'conversation', params: { token: params.token } }) @@ -436,8 +383,9 @@ 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) { + } else if (!this.warnLeaving || this.skipLeaveWarning || this.isVoiceRoom) { // Safe to navigate + // Note: voice rooms are intended to be left without confirmation. beforeRouteChangeListener(to, from, next) } else { spawnDialog(ConfirmDialog, { @@ -570,6 +518,107 @@ export default { this.$router.push({ name: 'root' }) } }, + + async joinCallAutomatically() { + if (this.isInCall || this.isVoiceRoom) { + this.callViewStore.setForceCallView(true) + + const enableAudio = !BrowserStorage.getItem('audioDisabled_' + this.token) + const enableVideo = !BrowserStorage.getItem('videoDisabled_' + this.token) + const enableVirtualBackground = !!BrowserStorage.getItem('virtualBackgroundEnabled') + const virtualBackgroundType = BrowserStorage.getItem('virtualBackgroundType') + const virtualBackgroundBlurStrength = BrowserStorage.getItem('virtualBackgroundBlurStrength') + const virtualBackgroundUrl = BrowserStorage.getItem('virtualBackgroundUrl') + + // Fetch conversation object, if it's not known yet to the client + if (!this.$store.getters.conversation(this.token)) { + await this.fetchSingleConversation(this.token) + } + + const conversation = this.$store.getters.conversation(this.token) + const previousParticipants = [] + if (conversation.type === CONVERSATION.TYPE.ONE_TO_ONE) { + previousParticipants.push(conversation.name) + } + + // Remove previous listener to prevent stacking on rapid token changes + if (this._joinCallHandler) { + EventBus.off('joined-conversation', this._joinCallHandler) + } + + this._joinCallHandler = async ({ token }) => { + if (this.token !== token) { + return + } + if (enableAudio) { + BrowserStorage.removeItem('audioDisabled_' + token) + } else { + BrowserStorage.setItem('audioDisabled_' + token, 'true') + } + if (enableVideo) { + BrowserStorage.removeItem('videoDisabled_' + token) + } else { + BrowserStorage.setItem('videoDisabled_' + token, 'true') + } + if (enableVirtualBackground) { + BrowserStorage.setItem('virtualBackgroundEnabled', 'true') + } else { + BrowserStorage.removeItem('virtualBackgroundEnabled') + } + if (virtualBackgroundType) { + BrowserStorage.setItem('virtualBackgroundType', virtualBackgroundType) + } else { + BrowserStorage.removeItem('virtualBackgroundType') + } + if (virtualBackgroundBlurStrength) { + BrowserStorage.setItem('virtualBackgroundBlurStrength', virtualBackgroundBlurStrength) + } else { + BrowserStorage.removeItem('virtualBackgroundBlurStrength') + } + if (virtualBackgroundUrl) { + BrowserStorage.setItem('virtualBackgroundUrl', virtualBackgroundUrl) + } else { + BrowserStorage.removeItem('virtualBackgroundUrl') + } + + const conversation = this.$store.getters.conversation(token) + + 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) { + flags |= PARTICIPANT.CALL_FLAG.WITH_VIDEO + } + + const payload = { + token: this.token, + participantIdentifier: this.actorStore.participantIdentifier, + flags, + silent: true, + recordingConsent: this.recordingConsentGiven, + } + + if (conversation.objectType === CONVERSATION.OBJECT_TYPE.EXTENDED) { + payload.silent = false + if (previousParticipants.length) { + payload.silentFor = previousParticipants + } + } + + await this.$store.dispatch('joinCall', payload) + + this.callViewStore.setForceCallView(false) + } + + const currentJoinedToken = SessionStorage.getItem('joined_conversation') + if (currentJoinedToken === this.token) { + this._joinCallHandler({ token: this.token }) + } else { + EventBus.once('joined-conversation', this._joinCallHandler) + } + } + }, }, } diff --git a/src/components/CallView/BottomBar.vue b/src/components/CallView/BottomBar.vue index 187f0b781d3..444e8cba4c8 100644 --- a/src/components/CallView/BottomBar.vue +++ b/src/components/CallView/BottomBar.vue @@ -36,7 +36,7 @@ import { useDocumentFullscreen, } from '../../composables/useDocumentFullscreen.ts' import { useGetToken } from '../../composables/useGetToken.ts' -import { CONVERSATION, PARTICIPANT } from '../../constants.ts' +import { ATTENDEE, CONVERSATION, PARTICIPANT } from '../../constants.ts' import { getTalkConfig, showTalkFeatureHint, @@ -71,6 +71,8 @@ const isMobile = useIsMobile() const conversation = computed(() => { return store.getters.conversation(token.value) || store.getters.dummyConversation }) +const isGuestActor = computed(() => actorStore.actorType === ATTENDEE.ACTOR_TYPE.GUESTS) +const isVoiceRoom = computed(() => Boolean(conversation.value.attributes & CONVERSATION.ATTRIBUTE.VOICE_ROOM)) const supportedReactions = computed(() => getTalkConfig(token.value, 'call', 'supported-reactions') || []) @@ -762,6 +764,7 @@ useHotKey('r', toggleHandRaised) diff --git a/src/components/ConversationIcon.vue b/src/components/ConversationIcon.vue index 2fca20b5dfe..9c98d276834 100644 --- a/src/components/ConversationIcon.vue +++ b/src/components/ConversationIcon.vue @@ -60,12 +60,14 @@