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 @@