Implement CallParticiantsUpdatePopup in compose.

This commit is contained in:
Alex Hart
2025-11-07 11:02:30 -04:00
committed by Michelle Tang
parent 632aec423f
commit dd8f36f280
6 changed files with 337 additions and 16 deletions

View File

@@ -83,11 +83,11 @@ public final class CallParticipantListUpdate {
}
@VisibleForTesting
static Wrapper createWrapper(@NonNull CallParticipant callParticipant) {
public static Wrapper createWrapper(@NonNull CallParticipant callParticipant) {
return new Wrapper(callParticipant);
}
static final class Wrapper {
public static final class Wrapper {
private final CallParticipant callParticipant;
private Wrapper(@NonNull CallParticipant callParticipant) {

View File

@@ -138,37 +138,40 @@ public class CallParticipantsListUpdatePopupWindow extends PopupWindow implement
}
private void setDescriptionForRecipients(@NonNull Set<CallParticipantListUpdate.Wrapper> recipients, boolean isAdded) {
descriptionTextView.setText(getDescriptionForRecipients(getContentView().getContext(), recipients, isAdded));
}
public static @NonNull String getDescriptionForRecipients(@NonNull Context context, @NonNull Set<CallParticipantListUpdate.Wrapper> recipients, boolean isAdded) {
Iterator<CallParticipantListUpdate.Wrapper> iterator = recipients.iterator();
Context context = getContentView().getContext();
String description;
switch (recipients.size()) {
case 0:
throw new IllegalArgumentException("Recipients must contain 1 or more entries");
case 1:
description = context.getString(getOneMemberDescriptionResourceId(isAdded), getNextDisplayName(iterator));
description = context.getString(getOneMemberDescriptionResourceId(isAdded), getNextDisplayName(context, iterator));
break;
case 2:
description = context.getString(getTwoMemberDescriptionResourceId(isAdded), getNextDisplayName(iterator), getNextDisplayName(iterator));
description = context.getString(getTwoMemberDescriptionResourceId(isAdded), getNextDisplayName(context, iterator), getNextDisplayName(context, iterator));
break;
case 3:
description = context.getString(getThreeMemberDescriptionResourceId(isAdded), getNextDisplayName(iterator), getNextDisplayName(iterator), getNextDisplayName(iterator));
description = context.getString(getThreeMemberDescriptionResourceId(isAdded), getNextDisplayName(context, iterator), getNextDisplayName(context, iterator), getNextDisplayName(context, iterator));
break;
default:
description = context.getResources().getQuantityString(getManyMemberDescriptionResourceId(isAdded), recipients.size() - 2, getNextDisplayName(iterator), getNextDisplayName(iterator), recipients.size() - 2);
description = context.getResources().getQuantityString(getManyMemberDescriptionResourceId(isAdded), recipients.size() - 2, getNextDisplayName(context, iterator), getNextDisplayName(context, iterator), recipients.size() - 2);
}
descriptionTextView.setText(description);
return description;
}
private @NonNull Recipient getNextRecipient(@NonNull Iterator<CallParticipantListUpdate.Wrapper> wrapperIterator) {
return wrapperIterator.next().getCallParticipant().getRecipient();
}
private @NonNull String getNextDisplayName(@NonNull Iterator<CallParticipantListUpdate.Wrapper> wrapperIterator) {
private static @NonNull String getNextDisplayName(@NonNull Context context, @NonNull Iterator<CallParticipantListUpdate.Wrapper> wrapperIterator) {
CallParticipantListUpdate.Wrapper wrapper = wrapperIterator.next();
return wrapper.getCallParticipant().getRecipientDisplayName(getContentView().getContext());
return wrapper.getCallParticipant().getRecipientDisplayName(context);
}
private static @StringRes int getOneMemberDescriptionResourceId(boolean isAdded) {

View File

@@ -0,0 +1,308 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.webrtc.v2
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableStateSetOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.NightPreview
import org.signal.core.ui.compose.Previews
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.AvatarImage
import org.thoughtcrime.securesms.components.settings.app.subscription.BadgeImageSmall
import org.thoughtcrime.securesms.components.webrtc.CallParticipantListUpdate
import org.thoughtcrime.securesms.components.webrtc.CallParticipantsListUpdatePopupWindow
import org.thoughtcrime.securesms.components.webrtc.v2.CallParticipantUpdatePopupController.DisplayState
import org.thoughtcrime.securesms.events.CallParticipant
import org.thoughtcrime.securesms.events.CallParticipantId
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* Popup which displays at the top of the screen as people enter and leave the call. Only displayed in group calls.
*/
@Composable
fun CallParticipantUpdatePopup(
controller: CallParticipantUpdatePopupController,
modifier: Modifier = Modifier
) {
val transitionState = remember { MutableTransitionState(controller.displayState != DisplayState.NONE) }
transitionState.targetState = controller.displayState != DisplayState.NONE
LaunchedEffect(transitionState.isIdle) {
if (transitionState.isIdle && !transitionState.targetState) {
controller.updateDisplay()
}
}
AnimatedVisibility(
visibleState = transitionState,
enter = slideInVertically { fullHeight -> -fullHeight } + fadeIn(),
exit = slideOutVertically { fullHeight -> -fullHeight } + fadeOut(),
modifier = modifier
.heightIn(min = 96.dp)
.fillMaxWidth()
) {
LaunchedEffect(controller.displayState, controller.participants) {
delay(controller.displayDuration)
controller.hide()
}
PopupContent(controller.displayState, participants = controller.participants)
}
}
/**
* Body of the call participants pop-up, displaying a description, avatar, and optional badge.
*/
@Composable
private fun PopupContent(
displayState: DisplayState,
participants: Set<CallParticipantListUpdate.Wrapper>
) {
val context = LocalContext.current
var previousDisplayState by remember { mutableStateOf(DisplayState.NONE) }
var previousDescription by remember { mutableStateOf("") }
var previousAvatarRecipient by remember { mutableStateOf(Recipient.UNKNOWN) }
val avatarRecipient by remember(displayState) {
derivedStateOf {
if (participants.isEmpty()) {
previousAvatarRecipient
} else {
participants.first().callParticipant.recipient
}
}
}
val description by remember(displayState) {
derivedStateOf {
val displayStateForDescription = if (displayState != DisplayState.NONE) {
displayState
} else {
previousDisplayState
}
previousDisplayState = displayStateForDescription
if (participants.isNotEmpty()) {
previousDescription = CallParticipantsListUpdatePopupWindow.getDescriptionForRecipients(
context,
participants,
displayStateForDescription == DisplayState.ADD
)
}
previousDescription
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.wrapContentSize()
.padding(start = 12.dp, top = 30.dp, end = 12.dp)
.background(
color = colorResource(R.color.signal_light_colorSecondaryContainer),
shape = RoundedCornerShape(percent = 50)
)
) {
Box(
modifier = Modifier.size(48.dp)
) {
AvatarImage(
recipient = avatarRecipient,
modifier = Modifier
.padding(vertical = 8.dp)
.padding(start = 8.dp)
.size(32.dp)
)
BadgeImageSmall(
badge = avatarRecipient.featuredBadge,
modifier = Modifier.padding(top = 28.dp, start = 28.dp)
.size(16.dp)
)
}
Text(
text = description,
color = colorResource(R.color.signal_light_colorOnSecondaryContainer),
modifier = Modifier.padding(vertical = 14.dp).padding(start = 10.dp, end = 24.dp)
)
}
}
/**
* Controller owned by the [CallScreenMediator] which allows its callbacks to control this popup.
*/
@Stable
class CallParticipantUpdatePopupController(
val displayDuration: Duration = 10.seconds
) {
private var pendingAdditions = hashSetOf<CallParticipantListUpdate.Wrapper>()
private var pendingRemovals = hashSetOf<CallParticipantListUpdate.Wrapper>()
var participants = mutableStateSetOf<CallParticipantListUpdate.Wrapper>()
private set
var displayState: DisplayState by mutableStateOf(DisplayState.NONE)
private set
fun update(update: CallParticipantListUpdate) {
pendingAdditions.addAll(update.added)
pendingAdditions.removeAll(update.removed)
pendingRemovals.addAll(update.removed)
pendingRemovals.removeAll(update.added)
if (displayState == DisplayState.NONE) {
updateDisplay()
}
}
fun hide() {
displayState = DisplayState.NONE
}
fun updateDisplay() {
displayState = when {
pendingAdditions.isNotEmpty() -> {
DisplayState.ADD
}
pendingRemovals.isNotEmpty() -> {
DisplayState.REMOVE
}
else -> DisplayState.NONE
}
val toDisplay = pendingAdditions.ifEmpty {
pendingRemovals
}
participants.clear()
participants.addAll(toDisplay)
toDisplay.clear()
}
enum class DisplayState {
NONE,
ADD,
REMOVE
}
}
@NightPreview
@Composable
private fun PopupContentPreview() {
val participants = remember {
(1..10).map {
CallParticipant(
callParticipantId = CallParticipantId(it.toLong(), RecipientId.from(it.toLong())),
recipient = Recipient(
id = RecipientId.from(it.toLong()),
isResolving = false,
systemContactName = "Participant $it"
)
)
}
}
Previews.Preview {
PopupContent(
displayState = DisplayState.ADD,
participants = participants.take(1).map { CallParticipantListUpdate.createWrapper(it) }.toSet()
)
}
}
/**
* Interactive preview
*/
@NightPreview
@Composable
fun CallParticipantUpdatePopupPreview() {
val controller = remember { CallParticipantUpdatePopupController(displayDuration = 3.seconds) }
val participants = remember {
(1..10).map {
CallParticipant(
callParticipantId = CallParticipantId(it.toLong(), RecipientId.from(it.toLong())),
recipient = Recipient(
id = RecipientId.from(it.toLong()),
isResolving = false,
systemContactName = "Participant $it"
)
)
}
}
Previews.Preview {
Scaffold {
Row(
modifier = Modifier
.padding(it)
.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically
) {
Buttons.LargeTonal(
onClick = {
val randomParticipants = (1..2).map { participants.random() }.toSet().toList()
controller.update(CallParticipantListUpdate.computeDeltaUpdate(emptyList(), randomParticipants))
}
) {
Text("Add User")
}
Buttons.LargeTonal(
onClick = {
val randomParticipants = (1..2).map { participants.random() }.toSet().toList()
controller.update(CallParticipantListUpdate.computeDeltaUpdate(randomParticipants, emptyList()))
}
) {
Text("Remove User")
}
}
CallParticipantUpdatePopup(controller = controller, modifier = Modifier.fillMaxWidth())
}
}
}

View File

@@ -114,6 +114,7 @@ fun CallScreen(
additionalActionsListener: AdditionalActionsListener = AdditionalActionsListener.Empty,
callParticipantsPagerState: CallParticipantsPagerState,
pendingParticipantsListener: PendingParticipantsListener = PendingParticipantsListener.Empty,
callParticipantUpdatePopupController: CallParticipantUpdatePopupController,
overflowParticipants: List<CallParticipant>,
localParticipant: CallParticipant,
localRenderState: WebRtcLocalRenderState,
@@ -326,6 +327,13 @@ fun CallScreen(
pendingParticipantsListener = pendingParticipantsListener
)
}
if (callScreenState.isParticipantUpdatePopupEnabled) {
CallParticipantUpdatePopup(
controller = callParticipantUpdatePopupController,
modifier = Modifier.statusBarsPadding().fillMaxWidth()
)
}
}
}
}
@@ -730,7 +738,8 @@ private fun CallScreenPreview() {
onLocalPictureInPictureClicked = {},
overflowParticipants = participants,
onControlsToggled = {},
reactions = emptyList()
reactions = emptyList(),
callParticipantUpdatePopupController = remember { CallParticipantUpdatePopupController() }
)
}
}

View File

@@ -30,7 +30,7 @@ data class CallScreenState(
val displayAdditionalActionsDialog: Boolean = false,
val displayMissingPermissionsNotice: Boolean = false,
val pendingParticipantsState: PendingParticipantsState? = null,
val isParticipantUpdatePopupEnabled: Boolean = false,
val isParticipantUpdatePopupEnabled: Boolean = true,
val isCallStateUpdatePopupEnabled: Boolean = false,
val reactions: PersistentList<String> = persistentListOf()
) {

View File

@@ -62,6 +62,8 @@ class ComposeCallScreenMediator(private val activity: WebRtcCallActivity, viewMo
private val controlsVisibilityListener = MutableStateFlow<CallControlsVisibilityListener>(CallControlsVisibilityListener.Empty)
private val pendingParticipantsViewListener = MutableStateFlow<PendingParticipantsListener>(PendingParticipantsListener.Empty)
private val callParticipantUpdatePopupController = CallParticipantUpdatePopupController()
init {
WindowUtil.clearTranslucentNavigationBar(activity.window)
WindowUtil.clearTranslucentStatusBar(activity.window)
@@ -165,7 +167,8 @@ class ComposeCallScreenMediator(private val activity: WebRtcCallActivity, viewMo
onNavigationClick = { activity.onBackPressedDispatcher.onBackPressed() },
onLocalPictureInPictureClicked = viewModel::onLocalPictureInPictureClicked,
onControlsToggled = onControlsToggled,
onCallScreenDialogDismissed = { callScreenViewModel.dialog.update { CallScreenDialogType.NONE } }
onCallScreenDialogDismissed = { callScreenViewModel.dialog.update { CallScreenDialogType.NONE } },
callParticipantUpdatePopupController = callParticipantUpdatePopupController
)
}
}
@@ -288,7 +291,7 @@ class ComposeCallScreenMediator(private val activity: WebRtcCallActivity, viewMo
}
override fun onParticipantListUpdate(callParticipantListUpdate: CallParticipantListUpdate) {
callScreenViewModel.callParticipantListUpdate.update { callParticipantListUpdate }
callParticipantUpdatePopupController.update(callParticipantListUpdate)
}
override fun enableParticipantUpdatePopup(enabled: Boolean) {
@@ -348,8 +351,6 @@ class ComposeCallScreenMediator(private val activity: WebRtcCallActivity, viewMo
private var callControlsChangeJob: Job? = null
val callParticipantListUpdate = MutableStateFlow(CallParticipantListUpdate.computeDeltaUpdate(emptyList(), emptyList()))
fun emitControllerEvent(controllerEvent: CallScreenController.Event) {
viewModelScope.launch { callScreenControllerEvents.emit(controllerEvent) }
}