mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-24 13:08:46 +00:00
Implement CallParticiantsUpdatePopup in compose.
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
) {
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user