Skip to content
Draft
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
101 changes: 93 additions & 8 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@

<template>
<NcContent
:class="{ 'icon-loading': loading, 'in-call': isInCall }"
:class="{ 'icon-loading': loading, 'in-call': isInCall, 'call-minimized': isCallMinimized }"
appName="talk">
<MinimizedCallBar v-if="isCallMinimized" class="app-call-bar" />
<LeftSidebar v-if="getUserId" ref="leftSidebar" />
<NcAppContent>
<router-view />
Expand All @@ -33,6 +34,7 @@ import { provide } from 'vue'
import { START_LOCATION } from 'vue-router'
import NcAppContent from '@nextcloud/vue/components/NcAppContent'
import NcContent from '@nextcloud/vue/components/NcContent'
import MinimizedCallBar from './components/CallView/MinimizedCallBar.vue'
import ConversationSettingsDialog from './components/ConversationSettings/ConversationSettingsDialog.vue'
import LeftSidebar from './components/LeftSidebar/LeftSidebar.vue'
import MediaSettings from './components/MediaSettings/MediaSettings.vue'
Expand All @@ -50,7 +52,7 @@ import { useGetMessagesProvider } from './composables/useGetMessages.ts'
import { useGetToken } from './composables/useGetToken.ts'
import { useHashCheck } from './composables/useHashCheck.js'
import { useInterceptNotifications } from './composables/useInterceptNotifications.ts'
import { useIsInCall } from './composables/useIsInCall.js'
import { useCallMinimized, useIsInCall } from './composables/useIsInCall.js'
import { watchJoinedConversation } from './composables/useJoinedConversation.ts'
import { useRecordingStatusSync } from './composables/useRecordingStatusSync.ts'
import { useSessionIssueHandler } from './composables/useSessionIssueHandler.ts'
Expand Down Expand Up @@ -88,6 +90,7 @@ export default {
SettingsDialog,
ConversationSettingsDialog,
MediaSettings,
MinimizedCallBar,
PollManager,
},

Expand All @@ -107,6 +110,7 @@ export default {
token: useGetToken(),
tokenStore: useTokenStore(),
isInCall: useIsInCall(),
isCallMinimized: useCallMinimized(),
isLeavingAfterSessionIssue: useSessionIssueHandler(),
isMobile: useIsMobile(),
isNextcloudTalkHashDirty: useHashCheck(),
Expand Down Expand Up @@ -322,15 +326,38 @@ export default {
return
}

if (from.name === 'conversation' && from.params.token !== to.params.token) {
// Whether we are navigating away from the conversation that holds
// the active call. In that case we keep the call (and its signaling
// room) alive so it can be shown minimized, and open the target
// conversation as chat-only (no signaling join for it).
const keepCallAlive = from.name === 'conversation'
&& from.params.token !== to.params.token
&& this.callViewStore.activeCallToken === from.params.token
&& this.$store.getters.isInCall(from.params.token)

// Whether we are navigating back to the conversation that holds the
// active (minimized) call. Its session was never left, so we must
// NOT re-join it (that would trigger a "duplicate session" error).
const returningToActiveCall = to.name === 'conversation'
&& from.params.token !== to.params.token
&& this.callViewStore.activeCallToken === to.params.token
&& this.$store.getters.isInCall(to.params.token)

if (from.name === 'conversation' && from.params.token !== to.params.token && !keepCallAlive) {
// Await to properly close session / leave call before joining another one
await this.$store.dispatch('leaveConversation', { token: from.params.token })
}

/**
* This runs whenever the new route is a conversation.
*/
if (to.name === 'conversation' && from.params.token !== to.params.token) {
if (returningToActiveCall) {
// Returning to the active call's conversation: its session was
// never left, so do not re-join. Restore the "joined" marker
// (a chat-only visit to another conversation moved it away) so
// the call controls are enabled again.
this.tokenStore.updateLastJoinedConversationToken(to.params.token)
} else if (to.name === 'conversation' && from.params.token !== to.params.token) {
/**
* This runs whenever the new route is a conversation.
*/
// Fetch conversation object, if it's not known yet to the client
if (!this.$store.getters.conversation(to.params.token)) {
const result = await this.fetchSingleConversation(to.params.token)
Expand All @@ -340,7 +367,13 @@ export default {
return
}
}
this.$store.dispatch('joinConversation', { token: to.params.token })
// While a call is kept alive in another conversation, open the
// target as chat-only so we don't move the single signaling
// connection out of the call's room. Chat works over REST/poll.
// Awaited so the session is stable before next()/any follow-up
// joinCall reads the actor session id (otherwise a concurrent
// join can replace the session under a live call).
await this.$store.dispatch('joinConversation', { token: to.params.token, chatOnly: keepCallAlive })
}

next()
Expand Down Expand Up @@ -374,6 +407,10 @@ 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.canMinimizeCall(to, from)) {
// Navigating to another conversation while in a call: keep the
// call running and show it minimized instead of asking to leave.
beforeRouteChangeListener(to, from, next)
} else if (!this.warnLeaving || this.skipLeaveWarning || this.isVoiceRoom(from.params.token)) {
// Safe to navigate
// Note: voice rooms are intended to be left without confirmation.
Expand Down Expand Up @@ -423,6 +460,38 @@ export default {
return Boolean(conversation?.attributes & CONVERSATION.ATTRIBUTE.VOICE_ROOM)
},

/**
* Whether navigating from `from` to `to` should keep the active call
* running (minimized) instead of prompting to leave it. True both when
* leaving the active call's conversation to browse another one, and when
* returning to the active call's conversation.
*
* @param {object} to target route
* @param {object} from current route
* @return {boolean}
*/
canMinimizeCall(to, from) {
const activeCallToken = this.callViewStore.activeCallToken
if (!activeCallToken
|| from.name !== 'conversation'
|| to.name !== 'conversation'
|| from.params.token === to.params.token) {
return false
}
// Leaving the active call's conversation to browse another one.
if (activeCallToken === from.params.token
&& this.$store.getters.isInCall(from.params.token)
&& !this.isVoiceRoom(from.params.token)) {
return true
}
// Returning to the active call's conversation from elsewhere.
if (activeCallToken === to.params.token
&& this.$store.getters.isInCall(to.params.token)) {
return true
}
return false
},

preventUnload(event) {
if ((!this.warnLeaving && !this.isSendingMessages) || this.isVoiceRoom(this.token)) {
return
Expand Down Expand Up @@ -655,12 +724,28 @@ body#body-public {
<style lang="scss" scoped>

.content {
--call-bar-height: var(--default-clickable-area);

&.in-call {
:deep(.app-content) {
background-color: transparent;
}
}

// Minimized call bar: a full-width banner pinned just below the app header,
// pushing the navigation/content/sidebar columns down so it never overlaps
// existing UI (matches the "return to call" banner in Teams/Meet/Zoom).
&.call-minimized {
padding-block-start: var(--call-bar-height);

:deep(.app-call-bar) {
position: absolute;
inset-block-start: 0;
inset-inline: 0;
z-index: 1;
}
}

// Fix fullscreen black bar on top
&:fullscreen {
padding-top: 0;
Expand Down
197 changes: 197 additions & 0 deletions src/components/CallView/MinimizedCallBar.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->

<script setup lang="ts">
import type { Conversation } from '../../types/index.ts'

import { t } from '@nextcloud/l10n'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useStore } from 'vuex'
import NcButton from '@nextcloud/vue/components/NcButton'
import IconArrowExpand from 'vue-material-design-icons/ArrowExpand.vue'
import IconPhoneHangup from 'vue-material-design-icons/PhoneHangup.vue'
import IconPhoneInTalk from 'vue-material-design-icons/PhoneInTalk.vue'
import CallTime from '../TopBar/CallTime.vue'
import LocalAudioControlButton from './shared/LocalAudioControlButton.vue'
import { useActorStore } from '../../stores/actor.ts'
import { useCallViewStore } from '../../stores/callView.ts'
import { useTokenStore } from '../../stores/token.ts'
import { localMediaModel } from '../../utils/webrtc/index.js'

const router = useRouter()
const store = useStore()
const actorStore = useActorStore()
const callViewStore = useCallViewStore()
const tokenStore = useTokenStore()

const token = computed<string>(() => callViewStore.activeCallToken)
const conversation = computed<Conversation | undefined>(() => store.getters.conversation(token.value))

/**
* Navigate back to the conversation the call is running in, which restores the
* full-size call view and dismisses this bar.
*/
function returnToCall() {
router.push({ name: 'conversation', params: { token: token.value } })
}

/**
* Leave the active call. The leaveCall store action clears the active call
* token, which dismisses this bar. The actor session is kept on the call's
* conversation while minimized, so participantIdentifier targets it correctly.
*/
async function leaveCall() {
const callToken = token.value
// The conversation currently being browsed was opened chat-only (no active
// session, so the global session stayed on the call). Remember it before
// leaving so we can give it a real session afterwards.
const openToken = tokenStore.token

callViewStore.setSelectedVideoPeerId(null)
await store.dispatch('leaveCall', {
token: callToken,
participantIdentifier: actorStore.participantIdentifier,
all: false,
})

// Now that the call is gone (leaveCall cleared the active call token), the
// central guard in joinConversation no longer forces chat-only, so this
// establishes a real session for the conversation we are viewing. Without
// it the actor session stays on the (now left) call and starting a call
// here would fail. Done here exactly once; cannot race the start-call flow
// (confirmLeaveMinimizedCall) as those are distinct user actions.
if (openToken && openToken !== callToken) {
await store.dispatch('joinConversation', { token: openToken })
}
}
</script>

<template>
<div class="minimized-call-bar">
<button
class="minimized-call-bar__main"
:aria-label="t('spreed', 'Return to call')"
:title="t('spreed', 'Return to call')"
@click="returnToCall">
<IconPhoneInTalk :size="20" class="minimized-call-bar__icon" />
<span class="minimized-call-bar__label">
{{ t('spreed', 'Ongoing call in {name}', { name: conversation?.displayName ?? '' }) }}
</span>
<CallTime v-if="conversation" :start="conversation.callStartTime" class="minimized-call-bar__time" />
</button>

<div class="minimized-call-bar__controls">
<LocalAudioControlButton
v-if="conversation"
:token="token"
:conversation="conversation"
:model="localMediaModel"
variant="tertiary"
disableKeyboardShortcuts />
<NcButton
variant="tertiary"
@click="returnToCall">
<template #icon>
<IconArrowExpand :size="20" />
</template>
{{ t('spreed', 'Return to call') }}
</NcButton>
<NcButton
:aria-label="t('spreed', 'Leave call')"
:title="t('spreed', 'Leave call')"
variant="error"
@click="leaveCall">
<template #icon>
<IconPhoneHangup :size="20" />
</template>
</NcButton>
</div>
</div>
</template>

<style lang="scss" scoped>
@use '../../assets/variables.scss' as *;

// The bar uses the same dark "call chrome" as the rest of the call UI, so
// white text/icons keep full contrast and the red leave button stands out
// (matches the "return to call" banner pattern in Teams/Meet/Zoom).
.minimized-call-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--default-grid-baseline);

width: 100%;
height: var(--call-bar-height);
padding-inline: var(--default-grid-baseline);

color: #ffffff;
background-color: $color-call-background;
}

.minimized-call-bar__main {
display: flex;
align-items: center;
gap: var(--default-grid-baseline);

min-width: 0;
height: var(--default-clickable-area);
padding-inline: var(--default-grid-baseline);
border: none;
border-radius: var(--border-radius-element);

color: inherit;
background-color: transparent;
cursor: pointer;

&:hover,
&:focus-visible {
background-color: rgba(255, 255, 255, 0.1);
}
}

.minimized-call-bar__icon {
flex: 0 0 auto;
}

.minimized-call-bar__label {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-weight: 600;
}

.minimized-call-bar__time {
flex: 0 0 auto;
opacity: 0.85;
}

.minimized-call-bar__controls {
display: flex;
align-items: center;
gap: calc(0.5 * var(--default-grid-baseline));
flex: 0 0 auto;

// White-on-dark for the reused tertiary control buttons (mute, return),
// with a translucent-white hover/focus consistent with the call chrome.
:deep(.button-vue--vue-tertiary),
:deep(.button-vue--tertiary) {
color: #ffffff;

&:hover,
&:focus-visible {
background-color: rgba(255, 255, 255, 0.1) !important;
}
}
}

// On mobile only the timer + controls fit; the long label collapses.
@media (max-width: 768px) {
.minimized-call-bar__label {
display: none;
}
}
</style>
Loading
Loading