Reimplement the call overflow menu in compose.

This commit is contained in:
Alex Hart
2025-02-20 14:15:56 -04:00
committed by Greyson Parrelli
parent 993192d38e
commit 47ce28a721
8 changed files with 407 additions and 10 deletions

View File

@@ -0,0 +1,202 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.webrtc.v2
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.persistentListOf
import org.signal.core.ui.DarkPreview
import org.signal.core.ui.Previews
import org.signal.core.ui.TriggerAlignedPopup
import org.signal.core.ui.TriggerAlignedPopupState
import org.signal.core.ui.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.emoji.Emojifier
data class AdditionalActionsState(
val triggerAlignedPopupState: TriggerAlignedPopupState,
val isShown: Boolean = false,
val reactions: PersistentList<String> = persistentListOf(),
val isSelfHandRaised: Boolean = false,
@Stable val listener: AdditionalActionsListener = AdditionalActionsListener.Empty
)
interface AdditionalActionsListener {
fun onReactClick(reaction: String)
fun onReactWithAnyClick()
fun onRaiseHandClick(raised: Boolean)
object Empty : AdditionalActionsListener {
override fun onReactClick(reaction: String) = Unit
override fun onReactWithAnyClick() = Unit
override fun onRaiseHandClick(raised: Boolean) = Unit
}
}
@Composable
fun AdditionalActionsPopup(
onDismissRequest: () -> Unit,
state: AdditionalActionsState
) {
TriggerAlignedPopup(
onDismissRequest = onDismissRequest,
state = state.triggerAlignedPopupState
) {
Column(
verticalArrangement = spacedBy(12.dp),
modifier = Modifier
.width(320.dp)
.padding(12.dp)
) {
CallReactionScrubber(
reactions = state.reactions,
listener = state.listener
)
CallScreenMenu(
onRaiseHandClick = state.listener::onRaiseHandClick,
isSelfHandRaised = state.isSelfHandRaised
)
}
}
}
@Composable
private fun CallReactionScrubber(
reactions: PersistentList<String>,
listener: AdditionalActionsListener
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.background(SignalTheme.colors.colorSurface2, RoundedCornerShape(percent = 50))
.padding(start = 6.dp, top = 12.dp, bottom = 12.dp, end = 12.dp)
) {
reactions.forEach {
Emojifier(it) { annotatedText, inlineContent ->
Text(
text = annotatedText,
inlineContent = inlineContent,
style = MaterialTheme.typography.headlineLarge,
textAlign = TextAlign.Center,
modifier = Modifier
.width(44.dp)
.clickable(onClick = {
listener.onReactClick(it)
})
)
}
}
Spacer(modifier = Modifier.width(6.dp))
IconButton(
onClick = listener::onReactWithAnyClick,
modifier = Modifier.size(32.dp)
) {
Image(
imageVector = ImageVector.vectorResource(R.drawable.ic_any_emoji_32),
contentDescription = null
)
}
}
}
@Composable
private fun CallScreenMenu(
isSelfHandRaised: Boolean,
onRaiseHandClick: (Boolean) -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(SignalTheme.colors.colorSurface2, RoundedCornerShape(18.dp))
) {
CallScreenMenuOption(
imageVector = ImageVector.vectorResource(R.drawable.symbol_raise_hand_24),
title = if (isSelfHandRaised) stringResource(R.string.CallOverflowPopupWindow__lower_hand) else stringResource(R.string.CallOverflowPopupWindow__raise_hand),
onClick = { onRaiseHandClick(!isSelfHandRaised) }
)
}
}
@Composable
private fun CallScreenMenuOption(
imageVector: ImageVector,
title: String,
onClick: () -> Unit
) {
Row(
horizontalArrangement = spacedBy(16.dp),
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(18.dp))
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp)
) {
Icon(
imageVector = imageVector,
contentDescription = title,
tint = MaterialTheme.colorScheme.onSurface
)
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
}
}
@DarkPreview
@Composable
private fun CallScreenAdditionalActionsPopupPreview() {
Previews.Preview {
AdditionalActionsPopup(
onDismissRequest = {},
state = AdditionalActionsState(
isShown = false,
reactions = persistentListOf(
"\u2764\ufe0f",
"\ud83d\udc4d",
"\ud83d\udc4e",
"\ud83d\ude02",
"\ud83d\ude2e",
"\ud83d\ude22"
),
isSelfHandRaised = false,
listener = AdditionalActionsListener.Empty,
triggerAlignedPopupState = TriggerAlignedPopupState.rememberTriggerAlignedPopupState()
)
)
}
}

View File

@@ -31,6 +31,8 @@ import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import org.signal.core.ui.DarkPreview
import org.signal.core.ui.Previews
import org.signal.core.ui.TriggerAlignedPopupState.Companion.popupTrigger
import org.signal.core.ui.TriggerAlignedPopupState.Companion.rememberTriggerAlignedPopupState
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.webrtc.CallParticipantsState
import org.thoughtcrime.securesms.components.webrtc.ToggleButtonOutputState
@@ -50,6 +52,7 @@ fun CallControls(
callControlsState: CallControlsState,
callScreenControlsListener: CallScreenControlsListener,
callScreenSheetDisplayListener: CallScreenSheetDisplayListener,
additionalActionsState: AdditionalActionsState,
modifier: Modifier = Modifier
) {
val isPortrait = LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT
@@ -138,7 +141,10 @@ fun CallControls(
}
if (callControlsState.displayAdditionalActions) {
AdditionalActionsButton(onClick = callScreenControlsListener::onOverflowClicked)
AdditionalActionsButton(
onClick = callScreenControlsListener::onOverflowClicked,
modifier = Modifier.popupTrigger(additionalActionsState.triggerAlignedPopupState)
)
}
if (callControlsState.displayEndCallButton) {
@@ -187,7 +193,10 @@ fun CallControlsPreview() {
),
displayVideoTooltip = false,
callScreenControlsListener = CallScreenControlsListener.Empty,
callScreenSheetDisplayListener = CallScreenSheetDisplayListener.Empty
callScreenSheetDisplayListener = CallScreenSheetDisplayListener.Empty,
additionalActionsState = AdditionalActionsState(
triggerAlignedPopupState = rememberTriggerAlignedPopupState()
)
)
}
}
@@ -197,10 +206,12 @@ fun CallControlsPreview() {
*/
interface CallScreenSheetDisplayListener {
fun onAudioDeviceSheetDisplayChanged(displayed: Boolean)
fun onOverflowDisplayChanged(displayed: Boolean)
fun onVideoTooltipDismissed()
object Empty : CallScreenSheetDisplayListener {
override fun onAudioDeviceSheetDisplayChanged(displayed: Boolean) = Unit
override fun onOverflowDisplayChanged(displayed: Boolean) = Unit
override fun onVideoTooltipDismissed() = Unit
}
}

View File

@@ -56,6 +56,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.signal.core.ui.BottomSheets
import org.signal.core.ui.Previews
import org.signal.core.ui.TriggerAlignedPopupState
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.components.webrtc.WebRtcLocalRenderState
import org.thoughtcrime.securesms.events.CallParticipant
@@ -86,6 +87,7 @@ fun CallScreen(
),
callScreenControlsListener: CallScreenControlsListener = CallScreenControlsListener.Empty,
callScreenSheetDisplayListener: CallScreenSheetDisplayListener = CallScreenSheetDisplayListener.Empty,
additionalActionsListener: AdditionalActionsListener = AdditionalActionsListener.Empty,
callParticipantsPagerState: CallParticipantsPagerState,
pendingParticipantsListener: PendingParticipantsListener = PendingParticipantsListener.Empty,
overflowParticipants: List<CallParticipant>,
@@ -117,6 +119,21 @@ fun CallScreen(
val scope = rememberCoroutineScope()
val isPortrait = LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT
val additionalActionsPopupState = TriggerAlignedPopupState.rememberTriggerAlignedPopupState()
val additionalActionsState = remember(
callScreenState.reactions,
localParticipant.isHandRaised
) {
AdditionalActionsState(
reactions = callScreenState.reactions,
isSelfHandRaised = localParticipant.isHandRaised,
listener = additionalActionsListener,
triggerAlignedPopupState = additionalActionsPopupState
)
}
additionalActionsPopupState.display = callScreenState.displayAdditionalActionsDialog
BoxWithConstraints {
val maxHeight = constraints.maxHeight
val maxSheetHeight = round(constraints.maxHeight * 0.66f)
@@ -133,6 +150,11 @@ fun CallScreen(
sheetContent = {
BottomSheets.Handle(modifier = Modifier.align(Alignment.CenterHorizontally))
AdditionalActionsPopup(
onDismissRequest = callScreenControlsListener::onDismissOverflow,
state = additionalActionsState
)
Box(
modifier = Modifier
.fillMaxWidth()
@@ -159,6 +181,7 @@ fun CallScreen(
callScreenControlsListener = callScreenControlsListener,
callScreenSheetDisplayListener = callScreenSheetDisplayListener,
displayVideoTooltip = callScreenState.displayVideoTooltip,
additionalActionsState = additionalActionsState,
modifier = Modifier
.fillMaxWidth()
.alpha(callControlsAlpha)
@@ -304,7 +327,7 @@ private fun BoxScope.Viewport(
val isPortrait = LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT
val scope = rememberCoroutineScope()
val hideSheet by rememberUpdatedState(newValue = scaffoldState.bottomSheetState.currentValue == SheetValue.PartiallyExpanded && !callControlsState.skipHiddenState && !callScreenState.isDisplayingAudioToggleSheet)
val hideSheet by rememberUpdatedState(newValue = scaffoldState.bottomSheetState.currentValue == SheetValue.PartiallyExpanded && !callControlsState.skipHiddenState && !callScreenState.isDisplayingControlMenu())
LaunchedEffect(callScreenController.restartTimerRequests, hideSheet) {
if (hideSheet) {
delay(5.seconds)

View File

@@ -23,6 +23,7 @@ interface CallScreenControlsListener {
fun onVideoChanged(isVideoEnabled: Boolean)
fun onMicChanged(isMicEnabled: Boolean)
fun onOverflowClicked()
fun onDismissOverflow()
fun onCameraDirectionChanged()
fun onEndCallPressed()
fun onDenyCallPressed()
@@ -44,6 +45,7 @@ interface CallScreenControlsListener {
override fun onVideoChanged(isVideoEnabled: Boolean) = Unit
override fun onMicChanged(isMicEnabled: Boolean) = Unit
override fun onOverflowClicked() = Unit
override fun onDismissOverflow() = Unit
override fun onCameraDirectionChanged() = Unit
override fun onEndCallPressed() = Unit
override fun onDenyCallPressed() = Unit

View File

@@ -5,6 +5,8 @@
package org.thoughtcrime.securesms.components.webrtc.v2
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.persistentListOf
import org.thoughtcrime.securesms.recipients.RecipientId
/**
@@ -25,9 +27,12 @@ data class CallScreenState(
val displayVideoTooltip: Boolean = false,
val displaySwipeToSpeakerHint: Boolean = false,
val displayWifiToCellularPopup: Boolean = false,
val displayAdditionalActionsPopup: Boolean = false,
val displayAdditionalActionsDialog: Boolean = false,
val displayMissingPermissionsNotice: Boolean = false,
val pendingParticipantsState: PendingParticipantsState? = null,
val isParticipantUpdatePopupEnabled: Boolean = false,
val isCallStateUpdatePopupEnabled: Boolean = false
)
val isCallStateUpdatePopupEnabled: Boolean = false,
val reactions: PersistentList<String> = persistentListOf()
) {
fun isDisplayingControlMenu(): Boolean = isDisplayingAudioToggleSheet || displayAdditionalActionsDialog
}

View File

@@ -15,6 +15,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
@@ -27,11 +28,15 @@ import org.signal.core.ui.theme.SignalTheme
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.components.webrtc.CallParticipantListUpdate
import org.thoughtcrime.securesms.components.webrtc.CallParticipantsState
import org.thoughtcrime.securesms.components.webrtc.CallReactionScrubber.Companion.CUSTOM_REACTION_BOTTOM_SHEET_TAG
import org.thoughtcrime.securesms.components.webrtc.WebRtcControls
import org.thoughtcrime.securesms.components.webrtc.controls.CallInfoView
import org.thoughtcrime.securesms.components.webrtc.controls.ControlsAndInfoViewModel
import org.thoughtcrime.securesms.components.webrtc.controls.RaiseHandSnackbar
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.events.WebRtcViewModel
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiBottomSheetDialogFragment
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.service.webrtc.state.WebRtcEphemeralState
import org.thoughtcrime.securesms.webrtc.CallParticipantsViewState
@@ -40,7 +45,7 @@ import kotlin.time.Duration.Companion.seconds
/**
* Compose call screen wrapper
*/
class ComposeCallScreenMediator(activity: WebRtcCallActivity, viewModel: WebRtcCallViewModel) : CallScreenMediator {
class ComposeCallScreenMediator(private val activity: WebRtcCallActivity, viewModel: WebRtcCallViewModel) : CallScreenMediator, AdditionalActionsListener {
companion object {
private val TAG = Log.tag(ComposeCallScreenMediator::class)
@@ -78,6 +83,10 @@ class ComposeCallScreenMediator(activity: WebRtcCallActivity, viewModel: WebRtcC
callScreenViewModel.callScreenState.update { it.copy(isDisplayingAudioToggleSheet = displayed) }
}
override fun onOverflowDisplayChanged(displayed: Boolean) {
callScreenViewModel.callScreenState.update { it.copy(displayAdditionalActionsDialog = displayed) }
}
override fun onVideoTooltipDismissed() {
callScreenViewModel.callScreenState.update { it.copy(displayVideoTooltip = false) }
}
@@ -117,6 +126,7 @@ class ComposeCallScreenMediator(activity: WebRtcCallActivity, viewModel: WebRtcC
callControlsState = callControlsState,
callScreenController = callScreenController,
callScreenControlsListener = callScreenControlsListener,
additionalActionsListener = this,
callScreenSheetDisplayListener = callScreenSheetDisplayListener,
callParticipantsPagerState = callParticipantsPagerState,
pendingParticipantsListener = pendingParticipantsListener,
@@ -163,7 +173,7 @@ class ComposeCallScreenMediator(activity: WebRtcCallActivity, viewModel: WebRtcC
override fun toggleOverflowPopup() {
callScreenViewModel.callScreenState.update {
it.copy(displayAdditionalActionsPopup = !it.displayAdditionalActionsPopup)
it.copy(displayAdditionalActionsDialog = !it.displayAdditionalActionsDialog)
}
}
@@ -262,7 +272,7 @@ class ComposeCallScreenMediator(activity: WebRtcCallActivity, viewModel: WebRtcC
}
override fun dismissCallOverflowPopup() {
callScreenViewModel.callScreenState.update { it.copy(displayAdditionalActionsPopup = false) }
callScreenViewModel.callScreenState.update { it.copy(displayAdditionalActionsDialog = false) }
}
override fun onParticipantListUpdate(callParticipantListUpdate: CallParticipantListUpdate) {
@@ -285,13 +295,35 @@ class ComposeCallScreenMediator(activity: WebRtcCallActivity, viewModel: WebRtcC
callScreenViewModel.callScreenState.update { it.copy(displayMissingPermissionsNotice = false) }
}
override fun onReactWithAnyClick() {
val bottomSheet = ReactWithAnyEmojiBottomSheetDialogFragment.createForCallingReactions()
bottomSheet.show(activity.supportFragmentManager, CUSTOM_REACTION_BOTTOM_SHEET_TAG)
callScreenViewModel.callScreenState.update { it.copy(displayAdditionalActionsDialog = false) }
}
override fun onReactClick(reaction: String) {
AppDependencies.signalCallManager.react(reaction)
callScreenViewModel.callScreenState.update { it.copy(displayAdditionalActionsDialog = false) }
}
override fun onRaiseHandClick(raised: Boolean) {
AppDependencies.signalCallManager.raiseHand(raised)
callScreenViewModel.callScreenState.update { it.copy(displayAdditionalActionsDialog = false) }
}
/**
* State holder for compose call screen
*/
class CallScreenViewModel : ViewModel() {
val callScreenControllerEvents = MutableSharedFlow<CallScreenController.Event>()
val callState = MutableStateFlow(WebRtcViewModel.State.IDLE)
val callScreenState = MutableStateFlow(CallScreenState())
val callScreenState = MutableStateFlow(
CallScreenState(
reactions = SignalStore.emoji.reactions.map {
SignalStore.emoji.getPreferredVariation(it)
}.toPersistentList()
)
)
val dialog = MutableStateFlow(CallScreenDialogType.NONE)
val callParticipantsViewState = MutableStateFlow(
CallParticipantsViewState(

View File

@@ -1139,6 +1139,10 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re
callScreen.toggleOverflowPopup()
}
override fun onDismissOverflow() {
callScreen.dismissCallOverflowPopup()
}
override fun onCameraDirectionChanged() {
handleFlipCamera()
}