Reimplement the call overflow menu in compose.

This commit is contained in:
Alex Hart
2025-02-20 16:25:41 -05:00
committed by Greyson Parrelli
parent 993192d38e
commit 47ce28a721
8 changed files with 407 additions and 10 deletions
@@ -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()
)
)
}
}
@@ -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
}
}
@@ -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)
@@ -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
@@ -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
}
@@ -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(
@@ -1139,6 +1139,10 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re
callScreen.toggleOverflowPopup()
}
override fun onDismissOverflow() {
callScreen.dismissCallOverflowPopup()
}
override fun onCameraDirectionChanged() {
handleFlipCamera()
}
@@ -0,0 +1,118 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui
import android.graphics.drawable.ColorDrawable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onPlaced
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.roundToIntRect
import androidx.compose.ui.window.DialogWindowProvider
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
import kotlin.math.max
import kotlin.math.min
/**
* Stores information related to the positional and display state of a
* [TriggerAlignedPopup].
*/
@Stable
class TriggerAlignedPopupState private constructor(
initialDisplay: Boolean = false,
initialTriggerBounds: IntRect = IntRect.Zero
) {
var display by mutableStateOf(initialDisplay)
private var triggerBounds by mutableStateOf(initialTriggerBounds)
val popupPositionProvider = derivedStateOf<PopupPositionProvider> {
object : PopupPositionProvider {
override fun calculatePosition(anchorBounds: IntRect, windowSize: IntSize, layoutDirection: LayoutDirection, popupContentSize: IntSize): IntOffset {
val desiredXOffset = triggerBounds.left + triggerBounds.width / 2 - popupContentSize.width / 2
val maxXOffset = windowSize.width - popupContentSize.width
return IntOffset(max(0, min(desiredXOffset, maxXOffset)), anchorBounds.top - popupContentSize.height)
}
}
}
companion object {
@Composable
fun rememberTriggerAlignedPopupState(): TriggerAlignedPopupState {
return rememberSaveable(
saver = Saver(
save = {
it.display to it.triggerBounds
},
restore = {
TriggerAlignedPopupState(
it.first,
it.second
)
}
)
) {
TriggerAlignedPopupState()
}
}
/**
* Sets the given composable as the popup trigger. This does NOT
* display the popup. Rather, it just sets positional information
* in the state. It is still up to the caller to call `state.displayed = true`
* in order to display the popup itself.
*/
fun Modifier.popupTrigger(state: TriggerAlignedPopupState): Modifier {
return this.onPlaced {
state.triggerBounds = it.boundsInWindow().roundToIntRect()
}
}
}
}
/**
* Focusable popup window that aligns itself with its trigger, if provided.
*
* See [TriggerAlignedPopupState.Companion.popupTrigger] for more information.
*/
@Composable
fun TriggerAlignedPopup(
state: TriggerAlignedPopupState,
onDismissRequest: () -> Unit = { state.display = false },
content: @Composable () -> Unit
) {
if (state.display) {
val positionProvider by state.popupPositionProvider
Popup(
properties = PopupProperties(focusable = true),
onDismissRequest = onDismissRequest,
popupPositionProvider = positionProvider
) {
(LocalView.current.parent as? DialogWindowProvider)?.apply {
this.window.setBackgroundDrawable(ColorDrawable(0))
}
content()
}
}
}