feat: render group member pictures in call UI, capped at 4 + remaining count

- Add GroupCallPictures composable showing up to 4 user avatars in
  a grid layout (same pattern as NonClickableUserPictures in chat)
- When >4 members, show 3 avatars + a "+N" badge in the 4th slot
- Add GroupCallNames composable showing up to 2 names + "+N" remaining
- Update CallInProgressUI, IncomingCallUI, ConnectedCallUI, PipCallUI,
  and PipConnectedCallUI to pass full peerPubKeys/groupMembers sets
  instead of only .first()

https://claude.ai/code/session_01LR8NmFGdMoDTVcfKjL7HWR
This commit is contained in:
Claude
2026-04-03 00:22:37 +00:00
parent 6946a5a263
commit 4e2143641f
@@ -68,6 +68,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
@@ -76,6 +78,7 @@ import com.vitorpamplona.amethyst.commons.call.CallManager
import com.vitorpamplona.amethyst.commons.call.CallState import com.vitorpamplona.amethyst.commons.call.CallState
import com.vitorpamplona.amethyst.service.call.AudioRoute import com.vitorpamplona.amethyst.service.call.AudioRoute
import com.vitorpamplona.amethyst.service.call.CallController import com.vitorpamplona.amethyst.service.call.CallController
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -123,10 +126,10 @@ fun CallScreen(
is CallState.Offering -> { is CallState.Offering -> {
if (isInPipMode) { if (isInPipMode) {
PipCallUI(peerPubKey = state.peerPubKeys.first(), statusText = stringRes(R.string.call_calling), accountViewModel = accountViewModel) PipCallUI(peerPubKeys = state.peerPubKeys, statusText = stringRes(R.string.call_calling), accountViewModel = accountViewModel)
} else { } else {
CallInProgressUI( CallInProgressUI(
peerPubKey = state.peerPubKeys.first(), peerPubKeys = state.peerPubKeys,
statusText = stringRes(R.string.call_calling), statusText = stringRes(R.string.call_calling),
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
onHangup = { scope.launch { callManager.hangup() } }, onHangup = { scope.launch { callManager.hangup() } },
@@ -136,7 +139,7 @@ fun CallScreen(
is CallState.IncomingCall -> { is CallState.IncomingCall -> {
if (isInPipMode) { if (isInPipMode) {
PipCallUI(callerPubKey = state.callerPubKey, statusText = stringRes(R.string.call_incoming), accountViewModel = accountViewModel) PipCallUI(peerPubKeys = state.groupMembers, statusText = stringRes(R.string.call_incoming), accountViewModel = accountViewModel)
} else { } else {
val isVideoCall = state.callType == com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType.VIDEO val isVideoCall = state.callType == com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType.VIDEO
val acceptWithPermission = val acceptWithPermission =
@@ -144,7 +147,7 @@ fun CallScreen(
callController?.acceptIncomingCall(state.sdpOffer) callController?.acceptIncomingCall(state.sdpOffer)
} }
IncomingCallUI( IncomingCallUI(
callerPubKey = state.callerPubKey, groupMembers = state.groupMembers,
callType = state.callType, callType = state.callType,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
onAccept = acceptWithPermission, onAccept = acceptWithPermission,
@@ -155,10 +158,10 @@ fun CallScreen(
is CallState.Connecting -> { is CallState.Connecting -> {
if (isInPipMode) { if (isInPipMode) {
PipCallUI(peerPubKey = state.peerPubKeys.first(), statusText = stringRes(R.string.call_connecting), accountViewModel = accountViewModel) PipCallUI(peerPubKeys = state.peerPubKeys, statusText = stringRes(R.string.call_connecting), accountViewModel = accountViewModel)
} else { } else {
CallInProgressUI( CallInProgressUI(
peerPubKey = state.peerPubKeys.first(), peerPubKeys = state.peerPubKeys,
statusText = stringRes(R.string.call_connecting), statusText = stringRes(R.string.call_connecting),
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
onHangup = { scope.launch { callManager.hangup() } }, onHangup = { scope.launch { callManager.hangup() } },
@@ -185,7 +188,7 @@ fun CallScreen(
is CallState.Ended -> { is CallState.Ended -> {
if (!isInPipMode) { if (!isInPipMode) {
CallInProgressUI( CallInProgressUI(
peerPubKey = state.peerPubKeys.first(), peerPubKeys = state.peerPubKeys,
statusText = stringRes(R.string.call_ended), statusText = stringRes(R.string.call_ended),
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
onHangup = { onCallEnded() }, onHangup = { onCallEnded() },
@@ -220,7 +223,7 @@ fun CallScreen(
@Composable @Composable
private fun CallInProgressUI( private fun CallInProgressUI(
peerPubKey: String, peerPubKeys: Set<String>,
statusText: String, statusText: String,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
onHangup: () -> Unit, onHangup: () -> Unit,
@@ -238,21 +241,16 @@ private fun CallInProgressUI(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
LoadUser(baseUserHex = peerPubKey, accountViewModel = accountViewModel) { user -> GroupCallPictures(
if (user != null) { peerPubKeys = peerPubKeys,
ClickableUserPicture( size = 120.dp,
baseUser = user, accountViewModel = accountViewModel,
size = 120.dp, )
accountViewModel = accountViewModel, Spacer(modifier = Modifier.height(16.dp))
) GroupCallNames(
Spacer(modifier = Modifier.height(16.dp)) peerPubKeys = peerPubKeys,
UsernameDisplay( accountViewModel = accountViewModel,
baseUser = user, )
accountViewModel = accountViewModel,
fontWeight = FontWeight.Bold,
)
}
}
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = statusText, text = statusText,
@@ -279,7 +277,7 @@ private fun CallInProgressUI(
@Composable @Composable
private fun IncomingCallUI( private fun IncomingCallUI(
callerPubKey: String, groupMembers: Set<String>,
callType: com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType, callType: com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
onAccept: () -> Unit, onAccept: () -> Unit,
@@ -298,21 +296,16 @@ private fun IncomingCallUI(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
LoadUser(baseUserHex = callerPubKey, accountViewModel = accountViewModel) { user -> GroupCallPictures(
if (user != null) { peerPubKeys = groupMembers,
ClickableUserPicture( size = 120.dp,
baseUser = user, accountViewModel = accountViewModel,
size = 120.dp, )
accountViewModel = accountViewModel, Spacer(modifier = Modifier.height(16.dp))
) GroupCallNames(
Spacer(modifier = Modifier.height(16.dp)) peerPubKeys = groupMembers,
UsernameDisplay( accountViewModel = accountViewModel,
baseUser = user, )
accountViewModel = accountViewModel,
fontWeight = FontWeight.Bold,
)
}
}
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = text =
@@ -433,22 +426,17 @@ private fun ConnectedCallUI(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
LoadUser(baseUserHex = state.peerPubKeys.first(), accountViewModel = accountViewModel) { user -> GroupCallPictures(
if (user != null) { peerPubKeys = state.peerPubKeys,
ClickableUserPicture( size = 120.dp,
baseUser = user, accountViewModel = accountViewModel,
size = 120.dp, )
accountViewModel = accountViewModel, Spacer(modifier = Modifier.height(16.dp))
) GroupCallNames(
Spacer(modifier = Modifier.height(16.dp)) peerPubKeys = state.peerPubKeys,
UsernameDisplay( accountViewModel = accountViewModel,
baseUser = user, textColor = Color.White,
accountViewModel = accountViewModel, )
fontWeight = FontWeight.Bold,
textColor = Color.White,
)
}
}
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = formatDuration(elapsed), text = formatDuration(elapsed),
@@ -584,12 +572,10 @@ private fun formatDuration(seconds: Long): String {
@Composable @Composable
private fun PipCallUI( private fun PipCallUI(
peerPubKey: String = "", peerPubKeys: Set<String>,
callerPubKey: String = "",
statusText: String, statusText: String,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
) { ) {
val pubKey = peerPubKey.ifEmpty { callerPubKey }
Box( Box(
modifier = modifier =
Modifier Modifier
@@ -601,15 +587,11 @@ private fun PipCallUI(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
LoadUser(baseUserHex = pubKey, accountViewModel = accountViewModel) { user -> GroupCallPictures(
if (user != null) { peerPubKeys = peerPubKeys,
ClickableUserPicture( size = 48.dp,
baseUser = user, accountViewModel = accountViewModel,
size = 48.dp, )
accountViewModel = accountViewModel,
)
}
}
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = statusText, text = statusText,
@@ -665,15 +647,11 @@ private fun PipConnectedCallUI(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
LoadUser(baseUserHex = state.peerPubKeys.first(), accountViewModel = accountViewModel) { user -> GroupCallPictures(
if (user != null) { peerPubKeys = state.peerPubKeys,
ClickableUserPicture( size = 48.dp,
baseUser = user, accountViewModel = accountViewModel,
size = 48.dp, )
accountViewModel = accountViewModel,
)
}
}
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = formatDuration(elapsed), text = formatDuration(elapsed),
@@ -696,6 +674,187 @@ private fun PipConnectedCallUI(
} }
} }
@Composable
private fun GroupCallPictures(
peerPubKeys: Set<String>,
size: Dp,
accountViewModel: AccountViewModel,
) {
val userList = remember(peerPubKeys) { peerPubKeys.toList() }
val displayCount = minOf(userList.size, 4)
val remaining = userList.size - displayCount
when (userList.size) {
0 -> {}
1 -> {
LoadUser(baseUserHex = userList[0], accountViewModel = accountViewModel) { user ->
if (user != null) {
ClickableUserPicture(
baseUser = user,
size = size,
accountViewModel = accountViewModel,
)
}
}
}
else -> {
Box(Modifier.size(size), contentAlignment = Alignment.TopEnd) {
when (displayCount) {
2 -> {
BaseUserPicture(
baseUserHex = userList[0],
size = size.div(1.5f),
accountViewModel = accountViewModel,
outerModifier = Modifier.size(size.div(1.5f)).align(Alignment.CenterStart),
)
BaseUserPicture(
baseUserHex = userList[1],
size = size.div(1.5f),
accountViewModel = accountViewModel,
outerModifier = Modifier.size(size.div(1.5f)).align(Alignment.CenterEnd),
)
}
3 -> {
BaseUserPicture(
baseUserHex = userList[0],
size = size.div(1.8f),
accountViewModel = accountViewModel,
outerModifier = Modifier.size(size.div(1.8f)).align(Alignment.BottomStart),
)
BaseUserPicture(
baseUserHex = userList[1],
size = size.div(1.8f),
accountViewModel = accountViewModel,
outerModifier = Modifier.size(size.div(1.8f)).align(Alignment.TopCenter),
)
BaseUserPicture(
baseUserHex = userList[2],
size = size.div(1.8f),
accountViewModel = accountViewModel,
outerModifier = Modifier.size(size.div(1.8f)).align(Alignment.BottomEnd),
)
}
else -> {
BaseUserPicture(
baseUserHex = userList[0],
size = size.div(2f),
accountViewModel = accountViewModel,
outerModifier = Modifier.size(size.div(2f)).align(Alignment.BottomStart),
)
BaseUserPicture(
baseUserHex = userList[1],
size = size.div(2f),
accountViewModel = accountViewModel,
outerModifier = Modifier.size(size.div(2f)).align(Alignment.TopStart),
)
BaseUserPicture(
baseUserHex = userList[2],
size = size.div(2f),
accountViewModel = accountViewModel,
outerModifier = Modifier.size(size.div(2f)).align(Alignment.BottomEnd),
)
if (remaining > 0) {
Box(
modifier =
Modifier
.size(size.div(2f))
.align(Alignment.TopEnd)
.background(
MaterialTheme.colorScheme.surfaceVariant,
CircleShape,
),
contentAlignment = Alignment.Center,
) {
Text(
text = "+$remaining",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = (size.value / 5).sp,
fontWeight = FontWeight.Bold,
)
}
} else {
BaseUserPicture(
baseUserHex = userList[3],
size = size.div(2f),
accountViewModel = accountViewModel,
outerModifier = Modifier.size(size.div(2f)).align(Alignment.TopEnd),
)
}
}
}
}
}
}
}
@Composable
private fun GroupCallNames(
peerPubKeys: Set<String>,
accountViewModel: AccountViewModel,
textColor: Color = Color.Unspecified,
) {
val userList = remember(peerPubKeys) { peerPubKeys.toList() }
when (userList.size) {
0 -> {}
1 -> {
LoadUser(baseUserHex = userList[0], accountViewModel = accountViewModel) { user ->
if (user != null) {
UsernameDisplay(
baseUser = user,
accountViewModel = accountViewModel,
fontWeight = FontWeight.Bold,
textColor = textColor,
)
}
}
}
else -> {
val displayCount = minOf(userList.size, 2)
val remaining = userList.size - displayCount
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
for (i in 0 until displayCount) {
if (i > 0) {
Text(
text = ", ",
fontWeight = FontWeight.Bold,
color = textColor,
)
}
LoadUser(baseUserHex = userList[i], accountViewModel = accountViewModel) { user ->
if (user != null) {
UsernameDisplay(
baseUser = user,
accountViewModel = accountViewModel,
fontWeight = FontWeight.Bold,
textColor = textColor,
)
}
}
}
if (remaining > 0) {
Text(
text = " +$remaining",
fontWeight = FontWeight.Bold,
color = textColor,
textAlign = TextAlign.Center,
)
}
}
}
}
}
@Composable @Composable
private fun KeepScreenOn() { private fun KeepScreenOn() {
val context = LocalContext.current val context = LocalContext.current