From 0c7aa67dd883fb109d9e553c4de3b52ec4ab4eaa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Apr 2026 00:58:50 +0000 Subject: [PATCH] feat: start group call on first answer, show pending peers The call now starts immediately when the first peer answers instead of waiting for all participants. Remaining peers are tracked as pending and shown in the UI with a "Waiting for others to join..." indicator. When a pending peer answers, they move to the connected set. If a pending peer rejects or hangs up, they're silently removed. Changes: - Add pendingPeerPubKeys to Connecting and Connected states - Add allPeerPubKeys helper on Connected for UI rendering - Split peers into connected/pending on first answer in onCallAnswered - Handle subsequent answers in Connecting and Connected states - Carry pending peers through Connecting -> Connected transition - Show all peers (connected + pending) in call UI avatars - Display "Waiting for others to join..." when pending peers exist https://claude.ai/code/session_01LR8NmFGdMoDTVcfKjL7HWR --- .../amethyst/ui/call/CallScreen.kt | 14 ++- amethyst/src/main/res/values/strings.xml | 1 + .../amethyst/commons/call/CallManager.kt | 86 +++++++++++++++---- .../amethyst/commons/call/CallState.kt | 7 +- 4 files changed, 89 insertions(+), 19 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt index e06f6cd34f..daee010852 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt @@ -427,13 +427,13 @@ private fun ConnectedCallUI( verticalArrangement = Arrangement.Center, ) { GroupCallPictures( - peerPubKeys = state.peerPubKeys, + peerPubKeys = state.allPeerPubKeys, size = 120.dp, accountViewModel = accountViewModel, ) Spacer(modifier = Modifier.height(16.dp)) GroupCallNames( - peerPubKeys = state.peerPubKeys, + peerPubKeys = state.allPeerPubKeys, accountViewModel = accountViewModel, textColor = Color.White, ) @@ -443,6 +443,14 @@ private fun ConnectedCallUI( color = Color.White.copy(alpha = 0.7f), fontSize = 16.sp, ) + if (state.pendingPeerPubKeys.isNotEmpty()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringRes(R.string.call_waiting_for_others), + color = Color.White.copy(alpha = 0.5f), + fontSize = 13.sp, + ) + } } } else { // Timer overlay @@ -648,7 +656,7 @@ private fun PipConnectedCallUI( verticalArrangement = Arrangement.Center, ) { GroupCallPictures( - peerPubKeys = state.peerPubKeys, + peerPubKeys = state.allPeerPubKeys, size = 48.dp, accountViewModel = accountViewModel, ) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index b2c1dba13b..49801f5cbe 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -796,6 +796,7 @@ Bluetooth Voice call Video call + Waiting for others to join\u2026 Failed to start call Failed to accept call Failed to create call session diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt index b025cdc94c..496b08d5b3 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt @@ -155,15 +155,37 @@ class CallManager( fun onCallAnswered(event: CallAnswerEvent) { val current = _state.value val callId = event.callId() + val answeringPeer = event.pubKey when (current) { is CallState.Offering -> { if (callId != current.callId) return - _state.value = CallState.Connecting(current.callId, current.peerPubKeys, current.callType) + // First answer: start the call immediately. Remaining peers stay pending. + val pending = current.peerPubKeys - answeringPeer + _state.value = + CallState.Connecting( + current.callId, + setOf(answeringPeer), + current.callType, + pendingPeerPubKeys = pending, + ) cancelTimeout() onAnswerReceived?.invoke(event) } + is CallState.Connecting -> { + // Another peer answered while we're still connecting with the first + if (callId != current.callId) return + if (answeringPeer in current.pendingPeerPubKeys) { + _state.value = + current.copy( + peerPubKeys = current.peerPubKeys + answeringPeer, + pendingPeerPubKeys = current.pendingPeerPubKeys - answeringPeer, + ) + } + // TODO: establish additional WebRTC peer connection for this peer + } + is CallState.IncomingCall -> { // Another device of this user answered the call — stop ringing. if (callId != current.callId) return @@ -171,9 +193,19 @@ class CallManager( } is CallState.Connected -> { - // Renegotiation answer (e.g., peer accepted our video upgrade offer) if (callId != current.callId) return - onAnswerReceived?.invoke(event) + if (answeringPeer in current.pendingPeerPubKeys) { + // A pending peer just joined the group call + _state.value = + current.copy( + peerPubKeys = current.peerPubKeys + answeringPeer, + pendingPeerPubKeys = current.pendingPeerPubKeys - answeringPeer, + ) + // TODO: establish additional WebRTC peer connection for this peer + } else { + // Renegotiation answer (e.g., peer accepted our video upgrade offer) + onAnswerReceived?.invoke(event) + } } else -> { @@ -198,6 +230,20 @@ class CallManager( } } + is CallState.Connecting -> { + // A pending peer rejected while we're already connecting with another + if (callId != current.callId) return + _state.value = + current.copy(pendingPeerPubKeys = current.pendingPeerPubKeys - rejectingPeer) + } + + is CallState.Connected -> { + // A pending peer rejected while we're already in the call + if (callId != current.callId) return + _state.value = + current.copy(pendingPeerPubKeys = current.pendingPeerPubKeys - rejectingPeer) + } + is CallState.IncomingCall -> { // Another device of this user rejected the call — stop ringing. if (callId != current.callId) return @@ -251,6 +297,7 @@ class CallManager( peerPubKeys = current.peerPubKeys, callType = current.callType, startedAtEpoch = TimeUtils.now(), + pendingPeerPubKeys = current.pendingPeerPubKeys, ) } @@ -296,21 +343,31 @@ class CallManager( when (current) { is CallState.Connected -> { if (callId != current.callId) return - val remaining = current.peerPubKeys - leavingPeer - if (remaining.isEmpty()) { - transitionToEnded(callId, current.peerPubKeys, EndReason.PEER_HANGUP) + val connectedRemaining = current.peerPubKeys - leavingPeer + val pendingRemaining = current.pendingPeerPubKeys - leavingPeer + if (connectedRemaining.isEmpty() && pendingRemaining.isEmpty()) { + transitionToEnded(callId, current.allPeerPubKeys, EndReason.PEER_HANGUP) } else { - _state.value = current.copy(peerPubKeys = remaining) + _state.value = + current.copy( + peerPubKeys = connectedRemaining, + pendingPeerPubKeys = pendingRemaining, + ) } } is CallState.Connecting -> { if (callId != current.callId) return - val remaining = current.peerPubKeys - leavingPeer - if (remaining.isEmpty()) { - transitionToEnded(callId, current.peerPubKeys, EndReason.PEER_HANGUP) + val connectedRemaining = current.peerPubKeys - leavingPeer + val pendingRemaining = current.pendingPeerPubKeys - leavingPeer + if (connectedRemaining.isEmpty() && pendingRemaining.isEmpty()) { + transitionToEnded(callId, current.peerPubKeys + current.pendingPeerPubKeys, EndReason.PEER_HANGUP) } else { - _state.value = current.copy(peerPubKeys = remaining) + _state.value = + current.copy( + peerPubKeys = connectedRemaining, + pendingPeerPubKeys = pendingRemaining, + ) } } @@ -326,7 +383,6 @@ class CallManager( is CallState.IncomingCall -> { if (callId != current.callId) return - // If the caller hung up, end the incoming call if (leavingPeer == current.callerPubKey) { transitionToEnded(callId, current.groupMembers, EndReason.PEER_HANGUP) } else { @@ -376,13 +432,13 @@ class CallManager( /** Returns the first peer pubkey (for P2P calls) or null. */ fun currentPeerPubKey(): HexKey? = currentPeerPubKeys()?.firstOrNull() - /** Returns all peer pubkeys for the current call. */ + /** Returns all peer pubkeys for the current call (connected + pending). */ fun currentPeerPubKeys(): Set? = when (val s = _state.value) { is CallState.Offering -> s.peerPubKeys is CallState.IncomingCall -> s.peerPubKeys() - is CallState.Connecting -> s.peerPubKeys - is CallState.Connected -> s.peerPubKeys + is CallState.Connecting -> s.peerPubKeys + s.pendingPeerPubKeys + is CallState.Connected -> s.allPeerPubKeys else -> null } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallState.kt index 940eefdaa8..001ebcd87d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallState.kt @@ -46,6 +46,7 @@ sealed interface CallState { val callId: String, val peerPubKeys: Set, val callType: CallType, + val pendingPeerPubKeys: Set = emptySet(), ) : CallState data class Connected( @@ -53,7 +54,11 @@ sealed interface CallState { val peerPubKeys: Set, val callType: CallType, val startedAtEpoch: Long, - ) : CallState + val pendingPeerPubKeys: Set = emptySet(), + ) : CallState { + /** All peers: connected + still pending. */ + val allPeerPubKeys: Set get() = peerPubKeys + pendingPeerPubKeys + } data class Ended( val callId: String,