Skip to content

Commit 171c5af

Browse files
Rikdekkerclaude
andcommitted
feat(call): keep call running while browsing other conversations (minimized call bar)
Navigating to another conversation while in a call used to end the call, forcing users to leave the call to read other chats. Now the call keeps running and is shown as a minimized in-call bar (conversation name, timer, mute, return-to-call, leave), while the other conversation opens as chat-only. Talk shares a single signaling connection that can only be in one room at a time, so the browsed conversation is opened without joining its signaling room: its chat works over REST + polling (no typing/presence push, indicated by a subtle hint). The call's conversation keeps the signaling room and the active session, so audio/video/screenshare keep flowing. Starting a call in another conversation while one is already running asks for confirmation first (leave the current call and join here), matching the behavior of Teams/Zoom/Meet. Works on web and the desktop client; the in-call bar adapts to a full-width top bar on mobile-web. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Rikdekker <Rikdekker@users.noreply.github.com>
1 parent 9c22274 commit 171c5af

10 files changed

Lines changed: 513 additions & 16 deletions

File tree

src/App.vue

Lines changed: 93 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@
55

66
<template>
77
<NcContent
8-
:class="{ 'icon-loading': loading, 'in-call': isInCall }"
8+
:class="{ 'icon-loading': loading, 'in-call': isInCall, 'call-minimized': isCallMinimized }"
99
appName="talk">
10+
<MinimizedCallBar v-if="isCallMinimized" class="app-call-bar" />
1011
<LeftSidebar v-if="getUserId" ref="leftSidebar" />
1112
<NcAppContent>
1213
<router-view />
@@ -33,6 +34,7 @@ import { provide } from 'vue'
3334
import { START_LOCATION } from 'vue-router'
3435
import NcAppContent from '@nextcloud/vue/components/NcAppContent'
3536
import NcContent from '@nextcloud/vue/components/NcContent'
37+
import MinimizedCallBar from './components/CallView/MinimizedCallBar.vue'
3638
import ConversationSettingsDialog from './components/ConversationSettings/ConversationSettingsDialog.vue'
3739
import LeftSidebar from './components/LeftSidebar/LeftSidebar.vue'
3840
import MediaSettings from './components/MediaSettings/MediaSettings.vue'
@@ -50,7 +52,7 @@ import { useGetMessagesProvider } from './composables/useGetMessages.ts'
5052
import { useGetToken } from './composables/useGetToken.ts'
5153
import { useHashCheck } from './composables/useHashCheck.js'
5254
import { useInterceptNotifications } from './composables/useInterceptNotifications.ts'
53-
import { useIsInCall } from './composables/useIsInCall.js'
55+
import { useCallMinimized, useIsInCall } from './composables/useIsInCall.js'
5456
import { watchJoinedConversation } from './composables/useJoinedConversation.ts'
5557
import { useRecordingStatusSync } from './composables/useRecordingStatusSync.ts'
5658
import { useSessionIssueHandler } from './composables/useSessionIssueHandler.ts'
@@ -88,6 +90,7 @@ export default {
8890
SettingsDialog,
8991
ConversationSettingsDialog,
9092
MediaSettings,
93+
MinimizedCallBar,
9194
PollManager,
9295
},
9396
@@ -107,6 +110,7 @@ export default {
107110
token: useGetToken(),
108111
tokenStore: useTokenStore(),
109112
isInCall: useIsInCall(),
113+
isCallMinimized: useCallMinimized(),
110114
isLeavingAfterSessionIssue: useSessionIssueHandler(),
111115
isMobile: useIsMobile(),
112116
isNextcloudTalkHashDirty: useHashCheck(),
@@ -322,15 +326,38 @@ export default {
322326
return
323327
}
324328
325-
if (from.name === 'conversation' && from.params.token !== to.params.token) {
329+
// Whether we are navigating away from the conversation that holds
330+
// the active call. In that case we keep the call (and its signaling
331+
// room) alive so it can be shown minimized, and open the target
332+
// conversation as chat-only (no signaling join for it).
333+
const keepCallAlive = from.name === 'conversation'
334+
&& from.params.token !== to.params.token
335+
&& this.callViewStore.activeCallToken === from.params.token
336+
&& this.$store.getters.isInCall(from.params.token)
337+
338+
// Whether we are navigating back to the conversation that holds the
339+
// active (minimized) call. Its session was never left, so we must
340+
// NOT re-join it (that would trigger a "duplicate session" error).
341+
const returningToActiveCall = to.name === 'conversation'
342+
&& from.params.token !== to.params.token
343+
&& this.callViewStore.activeCallToken === to.params.token
344+
&& this.$store.getters.isInCall(to.params.token)
345+
346+
if (from.name === 'conversation' && from.params.token !== to.params.token && !keepCallAlive) {
326347
// Await to properly close session / leave call before joining another one
327348
await this.$store.dispatch('leaveConversation', { token: from.params.token })
328349
}
329350
330-
/**
331-
* This runs whenever the new route is a conversation.
332-
*/
333-
if (to.name === 'conversation' && from.params.token !== to.params.token) {
351+
if (returningToActiveCall) {
352+
// Returning to the active call's conversation: its session was
353+
// never left, so do not re-join. Restore the "joined" marker
354+
// (a chat-only visit to another conversation moved it away) so
355+
// the call controls are enabled again.
356+
this.tokenStore.updateLastJoinedConversationToken(to.params.token)
357+
} else if (to.name === 'conversation' && from.params.token !== to.params.token) {
358+
/**
359+
* This runs whenever the new route is a conversation.
360+
*/
334361
// Fetch conversation object, if it's not known yet to the client
335362
if (!this.$store.getters.conversation(to.params.token)) {
336363
const result = await this.fetchSingleConversation(to.params.token)
@@ -340,7 +367,13 @@ export default {
340367
return
341368
}
342369
}
343-
this.$store.dispatch('joinConversation', { token: to.params.token })
370+
// While a call is kept alive in another conversation, open the
371+
// target as chat-only so we don't move the single signaling
372+
// connection out of the call's room. Chat works over REST/poll.
373+
// Awaited so the session is stable before next()/any follow-up
374+
// joinCall reads the actor session id (otherwise a concurrent
375+
// join can replace the session under a live call).
376+
await this.$store.dispatch('joinConversation', { token: to.params.token, chatOnly: keepCallAlive })
344377
}
345378
346379
next()
@@ -374,6 +407,10 @@ export default {
374407
if (from.name === 'conversation' && to.name === 'conversation' && from.params.token === to.params.token) {
375408
// Navigating within the same conversation
376409
beforeRouteChangeListener(to, from, next)
410+
} else if (this.canMinimizeCall(to, from)) {
411+
// Navigating to another conversation while in a call: keep the
412+
// call running and show it minimized instead of asking to leave.
413+
beforeRouteChangeListener(to, from, next)
377414
} else if (!this.warnLeaving || this.skipLeaveWarning || this.isVoiceRoom(from.params.token)) {
378415
// Safe to navigate
379416
// Note: voice rooms are intended to be left without confirmation.
@@ -423,6 +460,38 @@ export default {
423460
return Boolean(conversation?.attributes & CONVERSATION.ATTRIBUTE.VOICE_ROOM)
424461
},
425462
463+
/**
464+
* Whether navigating from `from` to `to` should keep the active call
465+
* running (minimized) instead of prompting to leave it. True both when
466+
* leaving the active call's conversation to browse another one, and when
467+
* returning to the active call's conversation.
468+
*
469+
* @param {object} to target route
470+
* @param {object} from current route
471+
* @return {boolean}
472+
*/
473+
canMinimizeCall(to, from) {
474+
const activeCallToken = this.callViewStore.activeCallToken
475+
if (!activeCallToken
476+
|| from.name !== 'conversation'
477+
|| to.name !== 'conversation'
478+
|| from.params.token === to.params.token) {
479+
return false
480+
}
481+
// Leaving the active call's conversation to browse another one.
482+
if (activeCallToken === from.params.token
483+
&& this.$store.getters.isInCall(from.params.token)
484+
&& !this.isVoiceRoom(from.params.token)) {
485+
return true
486+
}
487+
// Returning to the active call's conversation from elsewhere.
488+
if (activeCallToken === to.params.token
489+
&& this.$store.getters.isInCall(to.params.token)) {
490+
return true
491+
}
492+
return false
493+
},
494+
426495
preventUnload(event) {
427496
if ((!this.warnLeaving && !this.isSendingMessages) || this.isVoiceRoom(this.token)) {
428497
return
@@ -655,12 +724,28 @@ body#body-public {
655724
<style lang="scss" scoped>
656725
657726
.content {
727+
--call-bar-height: var(--default-clickable-area);
728+
658729
&.in-call {
659730
:deep(.app-content) {
660731
background-color: transparent;
661732
}
662733
}
663734
735+
// Minimized call bar: a full-width banner pinned just below the app header,
736+
// pushing the navigation/content/sidebar columns down so it never overlaps
737+
// existing UI (matches the "return to call" banner in Teams/Meet/Zoom).
738+
&.call-minimized {
739+
padding-block-start: var(--call-bar-height);
740+
741+
:deep(.app-call-bar) {
742+
position: absolute;
743+
inset-block-start: 0;
744+
inset-inline: 0;
745+
z-index: 1;
746+
}
747+
}
748+
664749
// Fix fullscreen black bar on top
665750
&:fullscreen {
666751
padding-top: 0;
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
<!--
2+
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
- SPDX-License-Identifier: AGPL-3.0-or-later
4+
-->
5+
6+
<script setup lang="ts">
7+
import type { Conversation } from '../../types/index.ts'
8+
9+
import { t } from '@nextcloud/l10n'
10+
import { computed } from 'vue'
11+
import { useRouter } from 'vue-router'
12+
import { useStore } from 'vuex'
13+
import NcButton from '@nextcloud/vue/components/NcButton'
14+
import IconArrowExpand from 'vue-material-design-icons/ArrowExpand.vue'
15+
import IconPhoneHangup from 'vue-material-design-icons/PhoneHangup.vue'
16+
import IconPhoneInTalk from 'vue-material-design-icons/PhoneInTalk.vue'
17+
import CallTime from '../TopBar/CallTime.vue'
18+
import LocalAudioControlButton from './shared/LocalAudioControlButton.vue'
19+
import { useActorStore } from '../../stores/actor.ts'
20+
import { useCallViewStore } from '../../stores/callView.ts'
21+
import { useTokenStore } from '../../stores/token.ts'
22+
import { localMediaModel } from '../../utils/webrtc/index.js'
23+
24+
const router = useRouter()
25+
const store = useStore()
26+
const actorStore = useActorStore()
27+
const callViewStore = useCallViewStore()
28+
const tokenStore = useTokenStore()
29+
30+
const token = computed<string>(() => callViewStore.activeCallToken)
31+
const conversation = computed<Conversation | undefined>(() => store.getters.conversation(token.value))
32+
33+
/**
34+
* Navigate back to the conversation the call is running in, which restores the
35+
* full-size call view and dismisses this bar.
36+
*/
37+
function returnToCall() {
38+
router.push({ name: 'conversation', params: { token: token.value } })
39+
}
40+
41+
/**
42+
* Leave the active call. The leaveCall store action clears the active call
43+
* token, which dismisses this bar. The actor session is kept on the call's
44+
* conversation while minimized, so participantIdentifier targets it correctly.
45+
*/
46+
async function leaveCall() {
47+
const callToken = token.value
48+
// The conversation currently being browsed was opened chat-only (no active
49+
// session, so the global session stayed on the call). Remember it before
50+
// leaving so we can give it a real session afterwards.
51+
const openToken = tokenStore.token
52+
53+
callViewStore.setSelectedVideoPeerId(null)
54+
await store.dispatch('leaveCall', {
55+
token: callToken,
56+
participantIdentifier: actorStore.participantIdentifier,
57+
all: false,
58+
})
59+
60+
// Now that the call is gone (leaveCall cleared the active call token), the
61+
// central guard in joinConversation no longer forces chat-only, so this
62+
// establishes a real session for the conversation we are viewing. Without
63+
// it the actor session stays on the (now left) call and starting a call
64+
// here would fail. Done here exactly once; cannot race the start-call flow
65+
// (confirmLeaveMinimizedCall) as those are distinct user actions.
66+
if (openToken && openToken !== callToken) {
67+
await store.dispatch('joinConversation', { token: openToken })
68+
}
69+
}
70+
</script>
71+
72+
<template>
73+
<div class="minimized-call-bar">
74+
<button
75+
class="minimized-call-bar__main"
76+
:aria-label="t('spreed', 'Return to call')"
77+
:title="t('spreed', 'Return to call')"
78+
@click="returnToCall">
79+
<IconPhoneInTalk :size="20" class="minimized-call-bar__icon" />
80+
<span class="minimized-call-bar__label">
81+
{{ t('spreed', 'Ongoing call in {name}', { name: conversation?.displayName ?? '' }) }}
82+
</span>
83+
<CallTime v-if="conversation" :start="conversation.callStartTime" class="minimized-call-bar__time" />
84+
</button>
85+
86+
<div class="minimized-call-bar__controls">
87+
<LocalAudioControlButton
88+
v-if="conversation"
89+
:token="token"
90+
:conversation="conversation"
91+
:model="localMediaModel"
92+
variant="tertiary"
93+
disableKeyboardShortcuts />
94+
<NcButton
95+
variant="tertiary"
96+
@click="returnToCall">
97+
<template #icon>
98+
<IconArrowExpand :size="20" />
99+
</template>
100+
{{ t('spreed', 'Return to call') }}
101+
</NcButton>
102+
<NcButton
103+
:aria-label="t('spreed', 'Leave call')"
104+
:title="t('spreed', 'Leave call')"
105+
variant="error"
106+
@click="leaveCall">
107+
<template #icon>
108+
<IconPhoneHangup :size="20" />
109+
</template>
110+
</NcButton>
111+
</div>
112+
</div>
113+
</template>
114+
115+
<style lang="scss" scoped>
116+
@use '../../assets/variables.scss' as *;
117+
118+
// The bar uses the same dark "call chrome" as the rest of the call UI, so
119+
// white text/icons keep full contrast and the red leave button stands out
120+
// (matches the "return to call" banner pattern in Teams/Meet/Zoom).
121+
.minimized-call-bar {
122+
display: flex;
123+
align-items: center;
124+
justify-content: space-between;
125+
gap: var(--default-grid-baseline);
126+
127+
width: 100%;
128+
height: var(--call-bar-height);
129+
padding-inline: var(--default-grid-baseline);
130+
131+
color: #ffffff;
132+
background-color: $color-call-background;
133+
}
134+
135+
.minimized-call-bar__main {
136+
display: flex;
137+
align-items: center;
138+
gap: var(--default-grid-baseline);
139+
140+
min-width: 0;
141+
height: var(--default-clickable-area);
142+
padding-inline: var(--default-grid-baseline);
143+
border: none;
144+
border-radius: var(--border-radius-element);
145+
146+
color: inherit;
147+
background-color: transparent;
148+
cursor: pointer;
149+
150+
&:hover,
151+
&:focus-visible {
152+
background-color: rgba(255, 255, 255, 0.1);
153+
}
154+
}
155+
156+
.minimized-call-bar__icon {
157+
flex: 0 0 auto;
158+
}
159+
160+
.minimized-call-bar__label {
161+
overflow: hidden;
162+
white-space: nowrap;
163+
text-overflow: ellipsis;
164+
font-weight: 600;
165+
}
166+
167+
.minimized-call-bar__time {
168+
flex: 0 0 auto;
169+
opacity: 0.85;
170+
}
171+
172+
.minimized-call-bar__controls {
173+
display: flex;
174+
align-items: center;
175+
gap: calc(0.5 * var(--default-grid-baseline));
176+
flex: 0 0 auto;
177+
178+
// White-on-dark for the reused tertiary control buttons (mute, return),
179+
// with a translucent-white hover/focus consistent with the call chrome.
180+
:deep(.button-vue--vue-tertiary),
181+
:deep(.button-vue--tertiary) {
182+
color: #ffffff;
183+
184+
&:hover,
185+
&:focus-visible {
186+
background-color: rgba(255, 255, 255, 0.1) !important;
187+
}
188+
}
189+
}
190+
191+
// On mobile only the timer + controls fit; the long label collapses.
192+
@media (max-width: 768px) {
193+
.minimized-call-bar__label {
194+
display: none;
195+
}
196+
}
197+
</style>

0 commit comments

Comments
 (0)