diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantListUpdate.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantListUpdate.java index 1ef82a8735..b0eae80107 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantListUpdate.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantListUpdate.java @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsListUpdatePopupWindow.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsListUpdatePopupWindow.java index 7de071b070..25acb123ed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsListUpdatePopupWindow.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsListUpdatePopupWindow.java @@ -138,37 +138,40 @@ public class CallParticipantsListUpdatePopupWindow extends PopupWindow implement } private void setDescriptionForRecipients(@NonNull Set recipients, boolean isAdded) { + descriptionTextView.setText(getDescriptionForRecipients(getContentView().getContext(), recipients, isAdded)); + } + + public static @NonNull String getDescriptionForRecipients(@NonNull Context context, @NonNull Set recipients, boolean isAdded) { Iterator 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 wrapperIterator) { return wrapperIterator.next().getCallParticipant().getRecipient(); } - private @NonNull String getNextDisplayName(@NonNull Iterator wrapperIterator) { + private static @NonNull String getNextDisplayName(@NonNull Context context, @NonNull Iterator wrapperIterator) { CallParticipantListUpdate.Wrapper wrapper = wrapperIterator.next(); - return wrapper.getCallParticipant().getRecipientDisplayName(getContentView().getContext()); + return wrapper.getCallParticipant().getRecipientDisplayName(context); } private static @StringRes int getOneMemberDescriptionResourceId(boolean isAdded) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantUpdatePopup.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantUpdatePopup.kt new file mode 100644 index 0000000000..b7fd3ed39b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantUpdatePopup.kt @@ -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 +) { + 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() + private var pendingRemovals = hashSetOf() + + var participants = mutableStateSetOf() + 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()) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreen.kt index 5acc7af279..65383f674a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreen.kt @@ -114,6 +114,7 @@ fun CallScreen( additionalActionsListener: AdditionalActionsListener = AdditionalActionsListener.Empty, callParticipantsPagerState: CallParticipantsPagerState, pendingParticipantsListener: PendingParticipantsListener = PendingParticipantsListener.Empty, + callParticipantUpdatePopupController: CallParticipantUpdatePopupController, overflowParticipants: List, 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() } ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenState.kt index 88c73b8ae5..f01467a2fa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenState.kt @@ -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 = persistentListOf() ) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/ComposeCallScreenMediator.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/ComposeCallScreenMediator.kt index 102fcc95ca..15f819175f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/ComposeCallScreenMediator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/ComposeCallScreenMediator.kt @@ -62,6 +62,8 @@ class ComposeCallScreenMediator(private val activity: WebRtcCallActivity, viewMo private val controlsVisibilityListener = MutableStateFlow(CallControlsVisibilityListener.Empty) private val pendingParticipantsViewListener = MutableStateFlow(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) } }