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
This commit is contained in:
Claude
2026-04-03 00:58:50 +00:00
parent 3962f75583
commit 0c7aa67dd8
4 changed files with 89 additions and 19 deletions
@@ -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,
)
+1
View File
@@ -796,6 +796,7 @@
<string name="call_bluetooth">Bluetooth</string>
<string name="call_voice">Voice call</string>
<string name="call_video">Video call</string>
<string name="call_waiting_for_others">Waiting for others to join\u2026</string>
<string name="call_failed_start">Failed to start call</string>
<string name="call_failed_accept">Failed to accept call</string>
<string name="call_failed_session">Failed to create call session</string>
@@ -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<HexKey>? =
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
}
@@ -46,6 +46,7 @@ sealed interface CallState {
val callId: String,
val peerPubKeys: Set<HexKey>,
val callType: CallType,
val pendingPeerPubKeys: Set<HexKey> = emptySet(),
) : CallState
data class Connected(
@@ -53,7 +54,11 @@ sealed interface CallState {
val peerPubKeys: Set<HexKey>,
val callType: CallType,
val startedAtEpoch: Long,
) : CallState
val pendingPeerPubKeys: Set<HexKey> = emptySet(),
) : CallState {
/** All peers: connected + still pending. */
val allPeerPubKeys: Set<HexKey> get() = peerPubKeys + pendingPeerPubKeys
}
data class Ended(
val callId: String,