mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-26 12:44:38 +00:00
Initial raise hand support.
This commit is contained in:
committed by
Cody Henthorne
parent
f2a7824168
commit
c2f5a6390e
@@ -10,7 +10,9 @@ import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.PopupWindow
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.widget.PopupWindowCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.WebRtcCallActivity
|
||||
@@ -22,15 +24,21 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
*/
|
||||
class CallOverflowPopupWindow(private val activity: WebRtcCallActivity, parentViewGroup: ViewGroup) : PopupWindow(
|
||||
LayoutInflater.from(activity).inflate(R.layout.call_overflow_holder, parentViewGroup, false),
|
||||
activity.resources.getDimension(R.dimen.reaction_scrubber_width).toInt(),
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
activity.resources.getDimension(R.dimen.calling_reaction_popup_menu_width).toInt(),
|
||||
activity.resources.getDimension(R.dimen.calling_reaction_popup_menu_height).toInt()
|
||||
|
||||
) {
|
||||
|
||||
init {
|
||||
(contentView as CallReactionScrubber).initialize(activity.supportFragmentManager, activity) {
|
||||
val root = (contentView as LinearLayout)
|
||||
root.findViewById<CallReactionScrubber>(R.id.reaction_scrubber).initialize(activity.supportFragmentManager) {
|
||||
ApplicationDependencies.getSignalCallManager().react(it)
|
||||
dismiss()
|
||||
}
|
||||
root.findViewById<ConstraintLayout>(R.id.raise_hand_layout_parent).setOnClickListener {
|
||||
ApplicationDependencies.getSignalCallManager().raiseHand(true)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
fun show(anchor: View) {
|
||||
@@ -45,7 +53,7 @@ class CallOverflowPopupWindow(private val activity: WebRtcCallActivity, parentVi
|
||||
val windowWidth = windowRect.width()
|
||||
val popupWidth = resources.getDimension(R.dimen.reaction_scrubber_width).toInt()
|
||||
|
||||
val popupHeight = resources.getDimension(R.dimen.calling_reaction_emoji_height).toInt()
|
||||
val popupHeight = resources.getDimension(R.dimen.calling_reaction_popup_menu_height).toInt()
|
||||
|
||||
val xOffset = windowWidth - popupWidth - margin
|
||||
val yOffset = -popupHeight - margin
|
||||
|
||||
@@ -8,6 +8,7 @@ import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.webrtc.WebRtcControls.FoldableState
|
||||
import org.thoughtcrime.securesms.events.CallParticipant
|
||||
import org.thoughtcrime.securesms.events.CallParticipant.Companion.createLocal
|
||||
import org.thoughtcrime.securesms.events.GroupCallRaiseHandEvent
|
||||
import org.thoughtcrime.securesms.events.GroupCallReactionEvent
|
||||
import org.thoughtcrime.securesms.events.WebRtcViewModel
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry
|
||||
@@ -26,10 +27,11 @@ data class CallParticipantsState(
|
||||
val callState: WebRtcViewModel.State = WebRtcViewModel.State.CALL_DISCONNECTED,
|
||||
val groupCallState: WebRtcViewModel.GroupCallState = WebRtcViewModel.GroupCallState.IDLE,
|
||||
private val remoteParticipants: ParticipantCollection = ParticipantCollection(SMALL_GROUP_MAX),
|
||||
val localParticipant: CallParticipant = createLocal(CameraState.UNKNOWN, BroadcastVideoSink(), false),
|
||||
val localParticipant: CallParticipant = createLocal(CameraState.UNKNOWN, BroadcastVideoSink(), microphoneEnabled = false, isHandRaised = false),
|
||||
val focusedParticipant: CallParticipant = CallParticipant.EMPTY,
|
||||
val localRenderState: WebRtcLocalRenderState = WebRtcLocalRenderState.GONE,
|
||||
val reactions: List<GroupCallReactionEvent> = emptyList(),
|
||||
val raisedHands: List<GroupCallRaiseHandEvent> = emptyList(),
|
||||
val isInPipMode: Boolean = false,
|
||||
private val showVideoForOutgoing: Boolean = false,
|
||||
val isViewingFocusedParticipant: Boolean = false,
|
||||
@@ -227,6 +229,7 @@ data class CallParticipantsState(
|
||||
localRenderState = localRenderState,
|
||||
showVideoForOutgoing = newShowVideoForOutgoing,
|
||||
recipient = webRtcViewModel.recipient,
|
||||
raisedHands = webRtcViewModel.raisedHands,
|
||||
remoteDevicesCount = webRtcViewModel.remoteDevicesCount,
|
||||
ringGroup = webRtcViewModel.ringGroup,
|
||||
isInOutgoingRingingMode = isInOutgoingRingingMode,
|
||||
|
||||
@@ -39,7 +39,7 @@ class CallReactionScrubber @JvmOverloads constructor(
|
||||
customEmojiIndex = emojiViews.size - 1
|
||||
}
|
||||
|
||||
fun initialize(fragmentManager: FragmentManager, callback: ReactWithAnyEmojiBottomSheetDialogFragment.Callback, listener: (String) -> Unit) {
|
||||
fun initialize(fragmentManager: FragmentManager, listener: (String) -> Unit) {
|
||||
val emojis = SignalStore.emojiValues().reactions
|
||||
for (i in emojiViews.indices) {
|
||||
val view = emojiViews[i]
|
||||
|
||||
@@ -24,6 +24,7 @@ import androidx.annotation.RequiresApi;
|
||||
import androidx.annotation.StringRes;
|
||||
import androidx.appcompat.content.res.AppCompatResources;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.compose.ui.platform.ComposeView;
|
||||
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||
import androidx.constraintlayout.widget.ConstraintSet;
|
||||
import androidx.constraintlayout.widget.Guideline;
|
||||
@@ -124,6 +125,8 @@ public class WebRtcCallView extends InsetAwareConstraintLayout {
|
||||
private RecyclerView groupReactionsFeed;
|
||||
private MultiReactionBurstLayout reactionViews;
|
||||
private Guideline aboveControlsGuideline;
|
||||
private ComposeView raiseHandSnackbar;
|
||||
|
||||
|
||||
|
||||
private WebRtcCallParticipantsPagerAdapter pagerAdapter;
|
||||
@@ -202,6 +205,7 @@ public class WebRtcCallView extends InsetAwareConstraintLayout {
|
||||
groupReactionsFeed = findViewById(R.id.call_screen_reactions_feed);
|
||||
reactionViews = findViewById(R.id.call_screen_reactions_container);
|
||||
aboveControlsGuideline = findViewById(R.id.call_screen_above_controls_guideline);
|
||||
raiseHandSnackbar = findViewById(R.id.call_screen_raise_hand_view);
|
||||
|
||||
View decline = findViewById(R.id.call_screen_decline_call);
|
||||
View answerLabel = findViewById(R.id.call_screen_answer_call_label);
|
||||
@@ -728,6 +732,10 @@ public class WebRtcCallView extends InsetAwareConstraintLayout {
|
||||
visibleViewSet.add(groupReactionsFeed);
|
||||
}
|
||||
|
||||
if (webRtcControls.displayRaiseHand()) {
|
||||
visibleViewSet.add(raiseHandSnackbar);
|
||||
}
|
||||
|
||||
boolean forceUpdate = webRtcControls.adjustForFold() && !controls.adjustForFold();
|
||||
controls = webRtcControls;
|
||||
|
||||
@@ -834,6 +842,12 @@ public class WebRtcCallView extends InsetAwareConstraintLayout {
|
||||
ConstraintSet.TOP,
|
||||
ViewUtil.dpToPx(layoutPositions.reactionBottomMargin));
|
||||
|
||||
constraintSet.connect(R.id.call_screen_raise_hand_view,
|
||||
ConstraintSet.BOTTOM,
|
||||
layoutPositions.reactionBottomViewId,
|
||||
ConstraintSet.TOP,
|
||||
ViewUtil.dpToPx(layoutPositions.reactionBottomMargin));
|
||||
|
||||
constraintSet.applyTo(this);
|
||||
}
|
||||
|
||||
|
||||
@@ -216,6 +216,10 @@ public final class WebRtcControls {
|
||||
return !isInPipMode;
|
||||
}
|
||||
|
||||
public boolean displayRaiseHand() {
|
||||
return FeatureFlags.groupCallRaiseHand() && !isInPipMode;
|
||||
}
|
||||
|
||||
public @NonNull WebRtcAudioOutput getAudioOutput() {
|
||||
switch (activeDevice) {
|
||||
case SPEAKER_PHONE:
|
||||
|
||||
@@ -23,6 +23,7 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -48,7 +49,9 @@ import org.signal.core.ui.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView
|
||||
import org.thoughtcrime.securesms.components.webrtc.WebRtcCallViewModel
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.events.CallParticipant
|
||||
import org.thoughtcrime.securesms.events.GroupCallRaiseHandEvent
|
||||
import org.thoughtcrime.securesms.events.WebRtcViewModel
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
@@ -72,7 +75,8 @@ object CallInfoView {
|
||||
remoteParticipants = state.allRemoteParticipants.sortedBy { it.callParticipantId.recipientId },
|
||||
localParticipant = state.localParticipant,
|
||||
groupMembers = state.groupMembers.filterNot { it.member.isSelf },
|
||||
callRecipient = state.recipient
|
||||
callRecipient = state.recipient,
|
||||
raisedHands = state.raisedHands
|
||||
)
|
||||
}
|
||||
.subscribeAsState(ParticipantsState())
|
||||
@@ -94,8 +98,9 @@ object CallInfoView {
|
||||
private fun CallInfoPreview() {
|
||||
SignalTheme(isDarkMode = true) {
|
||||
Surface {
|
||||
val remoteParticipants = listOf(CallParticipant(recipient = Recipient.UNKNOWN))
|
||||
CallInfo(
|
||||
participantsState = ParticipantsState(remoteParticipants = listOf(CallParticipant(recipient = Recipient.UNKNOWN))),
|
||||
participantsState = ParticipantsState(remoteParticipants = remoteParticipants, raisedHands = remoteParticipants.map { GroupCallRaiseHandEvent(it.recipient, System.currentTimeMillis()) }),
|
||||
controlAndInfoState = ControlAndInfoState()
|
||||
)
|
||||
}
|
||||
@@ -127,6 +132,31 @@ private fun CallInfo(
|
||||
)
|
||||
}
|
||||
|
||||
if (participantsState.raisedHands.isNotEmpty()) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 24.dp)
|
||||
.defaultMinSize(minHeight = 52.dp)
|
||||
.fillMaxWidth(),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
Text(
|
||||
text = pluralStringResource(id = R.plurals.CallParticipantsListDialog__raised_hands, count = participantsState.raisedHands.size, participantsState.raisedHands.size),
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
items(
|
||||
items = participantsState.raisedHands,
|
||||
key = { it.sender.id },
|
||||
contentType = { null }
|
||||
) {
|
||||
HandRaisedRow(recipient = it.sender)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -173,6 +203,8 @@ private fun CallInfo(
|
||||
showIcons = false,
|
||||
isVideoEnabled = false,
|
||||
isMicrophoneEnabled = false,
|
||||
showHandRaised = false,
|
||||
canLowerHand = false,
|
||||
isSelfAdmin = false,
|
||||
onBlockClicked = {}
|
||||
)
|
||||
@@ -214,6 +246,16 @@ private fun CallParticipantRowPreview() {
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun HandRaisedRowPreview() {
|
||||
SignalTheme(isDarkMode = true) {
|
||||
Surface {
|
||||
HandRaisedRow(Recipient.UNKNOWN, canLowerHand = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CallParticipantRow(
|
||||
callParticipant: CallParticipant,
|
||||
@@ -226,11 +268,28 @@ private fun CallParticipantRow(
|
||||
showIcons = true,
|
||||
isVideoEnabled = callParticipant.isVideoEnabled,
|
||||
isMicrophoneEnabled = callParticipant.isMicrophoneEnabled,
|
||||
showHandRaised = false,
|
||||
canLowerHand = false,
|
||||
isSelfAdmin = isSelfAdmin,
|
||||
onBlockClicked = { onBlockClicked(callParticipant) }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HandRaisedRow(recipient: Recipient, canLowerHand: Boolean = recipient.isSelf) {
|
||||
CallParticipantRow(
|
||||
initialRecipient = recipient,
|
||||
name = recipient.getShortDisplayName(LocalContext.current),
|
||||
showIcons = true,
|
||||
isVideoEnabled = true,
|
||||
isMicrophoneEnabled = true,
|
||||
showHandRaised = true,
|
||||
canLowerHand = canLowerHand,
|
||||
isSelfAdmin = false,
|
||||
onBlockClicked = {}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CallParticipantRow(
|
||||
initialRecipient: Recipient,
|
||||
@@ -238,6 +297,8 @@ private fun CallParticipantRow(
|
||||
showIcons: Boolean,
|
||||
isVideoEnabled: Boolean,
|
||||
isMicrophoneEnabled: Boolean,
|
||||
showHandRaised: Boolean,
|
||||
canLowerHand: Boolean,
|
||||
isSelfAdmin: Boolean,
|
||||
onBlockClicked: () -> Unit
|
||||
) {
|
||||
@@ -275,6 +336,25 @@ private fun CallParticipantRow(
|
||||
.align(Alignment.CenterVertically)
|
||||
)
|
||||
|
||||
if (showIcons && showHandRaised && canLowerHand) {
|
||||
TextButton(onClick = {
|
||||
if (recipient.isSelf) {
|
||||
ApplicationDependencies.getSignalCallManager().raiseHand(false)
|
||||
}
|
||||
}) {
|
||||
Text(text = stringResource(id = R.string.CallOverflowPopupWindow__lower_hand))
|
||||
}
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
}
|
||||
|
||||
if (showIcons && showHandRaised) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_raise_hand_24),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.align(Alignment.CenterVertically)
|
||||
)
|
||||
}
|
||||
|
||||
if (showIcons && !isVideoEnabled) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_video_slash_24),
|
||||
@@ -322,6 +402,8 @@ private fun GroupMemberRow(
|
||||
showIcons = false,
|
||||
isVideoEnabled = false,
|
||||
isMicrophoneEnabled = false,
|
||||
showHandRaised = false,
|
||||
canLowerHand = false,
|
||||
isSelfAdmin = isSelfAdmin
|
||||
) {}
|
||||
}
|
||||
@@ -334,7 +416,8 @@ private data class ParticipantsState(
|
||||
val remoteParticipants: List<CallParticipant> = emptyList(),
|
||||
val localParticipant: CallParticipant? = null,
|
||||
val groupMembers: List<GroupMemberEntry.FullMember> = emptyList(),
|
||||
val callRecipient: Recipient = Recipient.UNKNOWN
|
||||
val callRecipient: Recipient = Recipient.UNKNOWN,
|
||||
val raisedHands: List<GroupCallRaiseHandEvent> = emptyList()
|
||||
) {
|
||||
|
||||
val participantsForList: List<CallParticipant> = if (includeSelf && localParticipant != null) {
|
||||
|
||||
@@ -69,6 +69,7 @@ class ControlsAndInfoController(
|
||||
private val frame: FrameLayout
|
||||
private val behavior: BottomSheetBehavior<View>
|
||||
private val callInfoComposeView: ComposeView
|
||||
private val raiseHandComposeView: ComposeView
|
||||
private val callControls: ConstraintLayout
|
||||
private val bottomSheetVisibilityListeners = mutableSetOf<BottomSheetVisibilityListener>()
|
||||
private val scheduleHideControlsRunnable: Runnable = Runnable { onScheduledHide() }
|
||||
@@ -86,6 +87,7 @@ class ControlsAndInfoController(
|
||||
behavior = BottomSheetBehavior.from(frame)
|
||||
callInfoComposeView = webRtcCallView.findViewById(R.id.call_info_compose)
|
||||
callControls = webRtcCallView.findViewById(R.id.call_controls_constraint_layout)
|
||||
raiseHandComposeView = webRtcCallView.findViewById(R.id.call_screen_raise_hand_view)
|
||||
|
||||
callInfoComposeView.apply {
|
||||
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
|
||||
@@ -98,6 +100,13 @@ class ControlsAndInfoController(
|
||||
callInfoComposeView.alpha = 0f
|
||||
callInfoComposeView.translationY = infoTranslationDistance
|
||||
|
||||
raiseHandComposeView.apply {
|
||||
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
|
||||
setContent {
|
||||
RaiseHandSnackbar.View(viewModel, showCallInfoListener = ::showCallInfo)
|
||||
}
|
||||
}
|
||||
|
||||
frame.background = MaterialShapeDrawable(
|
||||
ShapeAppearanceModel.builder()
|
||||
.setTopLeftCorner(CornerFamily.ROUNDED, 18.dp.toFloat())
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.webrtc.controls
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rxjava3.subscribeAsState
|
||||
import androidx.compose.runtime.setValue
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.reactivex.rxjava3.core.BackpressureStrategy
|
||||
import kotlinx.coroutines.delay
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.webrtc.WebRtcCallViewModel
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.events.GroupCallRaiseHandEvent
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.time.Duration
|
||||
|
||||
/**
|
||||
* This is a UI element to display the status of one or more people with raised hands in a group call.
|
||||
* It supports both an expanded and collapsed mode.
|
||||
*/
|
||||
object RaiseHandSnackbar {
|
||||
const val TAG = "RaiseHandSnackbar"
|
||||
val COLLAPSE_DELAY_MS = TimeUnit.SECONDS.toMillis(4L)
|
||||
|
||||
@Composable
|
||||
fun View(webRtcCallViewModel: WebRtcCallViewModel, showCallInfoListener: () -> Unit, modifier: Modifier = Modifier) {
|
||||
var isExpanded by remember { mutableStateOf(ExpansionState(false, false)) }
|
||||
|
||||
val webRtcState by webRtcCallViewModel.callParticipantsState
|
||||
.toFlowable(BackpressureStrategy.LATEST)
|
||||
.map { state ->
|
||||
val raisedHands = state.raisedHands.sortedByDescending { it.timestamp }
|
||||
val shouldExpand = RaiseHandState.shouldExpand(raisedHands)
|
||||
if (!isExpanded.forced) {
|
||||
isExpanded = ExpansionState(shouldExpand, false)
|
||||
}
|
||||
raisedHands
|
||||
}.subscribeAsState(initial = emptyList())
|
||||
|
||||
val state by remember {
|
||||
derivedStateOf {
|
||||
RaiseHandState(raisedHands = webRtcState, expansionState = isExpanded)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(isExpanded) {
|
||||
delay(COLLAPSE_DELAY_MS)
|
||||
isExpanded = ExpansionState(false, false)
|
||||
}
|
||||
|
||||
RaiseHand(state, modifier, { isExpanded = ExpansionState(true, true) }, showCallInfoListener)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun RaiseHandSnackbarPreview() {
|
||||
RaiseHand(
|
||||
state = RaiseHandState(listOf(GroupCallRaiseHandEvent(Recipient.UNKNOWN, System.currentTimeMillis())))
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RaiseHand(
|
||||
state: RaiseHandState,
|
||||
modifier: Modifier = Modifier,
|
||||
setExpanded: (Boolean) -> Unit = {},
|
||||
showCallInfoListener: () -> Unit = {}
|
||||
) {
|
||||
AnimatedVisibility(visible = state.raisedHands.isNotEmpty()) {
|
||||
SignalTheme(
|
||||
isDarkMode = true
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.clip(shape = RoundedCornerShape(16.dp, 16.dp, 16.dp, 16.dp))
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
) {
|
||||
val boxModifier = modifier
|
||||
.height(48.dp)
|
||||
.animateContentSize()
|
||||
.padding(horizontal = 16.dp)
|
||||
.clickable(
|
||||
!state.expansionState.isExpanded,
|
||||
stringResource(id = R.string.CallOverflowPopupWindow__expand_snackbar_accessibility_label),
|
||||
Role.Button
|
||||
) { setExpanded(true) }
|
||||
|
||||
Box(
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
modifier = if (state.expansionState.isExpanded) {
|
||||
boxModifier.fillMaxWidth()
|
||||
} else {
|
||||
boxModifier.wrapContentWidth()
|
||||
}
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_raise_hand_24),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.align(Alignment.CenterVertically)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = getSnackbarText(state),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(start = 16.dp)
|
||||
)
|
||||
if (state.expansionState.isExpanded && state.raisedHands.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
if (state.raisedHands.first().sender.isSelf) {
|
||||
TextButton(onClick = {
|
||||
ApplicationDependencies.getSignalCallManager().raiseHand(false)
|
||||
}) {
|
||||
Text(text = stringResource(id = R.string.CallOverflowPopupWindow__lower_hand))
|
||||
}
|
||||
} else {
|
||||
TextButton(onClick = showCallInfoListener) {
|
||||
Text(text = stringResource(id = R.string.CallOverflowPopupWindow__view))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun getSnackbarText(state: RaiseHandState): String {
|
||||
if (state.isEmpty()) {
|
||||
return ""
|
||||
}
|
||||
return if (!state.expansionState.isExpanded) {
|
||||
pluralStringResource(id = R.plurals.CallRaiseHandSnackbar_raised_hands, count = state.raisedHands.size, getShortDisplayName(state.raisedHands), state.raisedHands.size - 1)
|
||||
} else {
|
||||
if (state.raisedHands.size == 1 && state.raisedHands.first().sender.isSelf) {
|
||||
stringResource(id = R.string.CallOverflowPopupWindow__you_raised_your_hand)
|
||||
} else {
|
||||
pluralStringResource(id = R.plurals.CallOverflowPopupWindow__raised_a_hand, count = state.raisedHands.size, state.raisedHands.first().sender.getShortDisplayName(LocalContext.current), state.raisedHands.size - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun getShortDisplayName(raisedHands: List<GroupCallRaiseHandEvent>): String {
|
||||
val recipient = raisedHands.first().sender
|
||||
return if (recipient.isSelf) {
|
||||
stringResource(id = R.string.CallParticipant__you)
|
||||
} else {
|
||||
recipient.getShortDisplayName(LocalContext.current)
|
||||
}
|
||||
}
|
||||
|
||||
private data class RaiseHandState(
|
||||
val raisedHands: List<GroupCallRaiseHandEvent> = emptyList(),
|
||||
val expansionState: ExpansionState = ExpansionState(false, false)
|
||||
) {
|
||||
|
||||
fun isEmpty(): Boolean {
|
||||
return raisedHands.isEmpty()
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun shouldExpand(raisedHands: List<GroupCallRaiseHandEvent>): Boolean {
|
||||
val now = System.currentTimeMillis()
|
||||
return raisedHands.any { it.getCollapseTimestamp() > now }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class ExpansionState(
|
||||
val isExpanded: Boolean,
|
||||
val forced: Boolean
|
||||
)
|
||||
@@ -16,6 +16,7 @@ data class CallParticipant constructor(
|
||||
val isForwardingVideo: Boolean = true,
|
||||
val isVideoEnabled: Boolean = false,
|
||||
val isMicrophoneEnabled: Boolean = false,
|
||||
val isHandRaised: Boolean = false,
|
||||
val lastSpoke: Long = 0,
|
||||
val audioLevel: AudioLevel? = null,
|
||||
val isMediaKeysReceived: Boolean = true,
|
||||
@@ -109,7 +110,8 @@ data class CallParticipant constructor(
|
||||
fun createLocal(
|
||||
cameraState: CameraState,
|
||||
renderer: BroadcastVideoSink,
|
||||
microphoneEnabled: Boolean
|
||||
microphoneEnabled: Boolean,
|
||||
isHandRaised: Boolean
|
||||
): CallParticipant {
|
||||
return CallParticipant(
|
||||
callParticipantId = CallParticipantId(Recipient.self()),
|
||||
@@ -117,7 +119,8 @@ data class CallParticipant constructor(
|
||||
videoSink = renderer,
|
||||
cameraState = cameraState,
|
||||
isVideoEnabled = cameraState.isEnabled && cameraState.cameraCount > 0,
|
||||
isMicrophoneEnabled = microphoneEnabled
|
||||
isMicrophoneEnabled = microphoneEnabled,
|
||||
isHandRaised = isHandRaised
|
||||
)
|
||||
}
|
||||
|
||||
@@ -130,6 +133,7 @@ data class CallParticipant constructor(
|
||||
isForwardingVideo: Boolean,
|
||||
audioEnabled: Boolean,
|
||||
videoEnabled: Boolean,
|
||||
isHandRaised: Boolean,
|
||||
lastSpoke: Long,
|
||||
mediaKeysReceived: Boolean,
|
||||
addedToCallTime: Long,
|
||||
@@ -144,6 +148,7 @@ data class CallParticipant constructor(
|
||||
isForwardingVideo = isForwardingVideo,
|
||||
isVideoEnabled = videoEnabled,
|
||||
isMicrophoneEnabled = audioEnabled,
|
||||
isHandRaised = isHandRaised,
|
||||
lastSpoke = lastSpoke,
|
||||
isMediaKeysReceived = mediaKeysReceived,
|
||||
addedToCallTime = addedToCallTime,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.events
|
||||
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
data class GroupCallRaiseHandEvent(val sender: Recipient, val timestamp: Long) {
|
||||
fun getCollapseTimestamp(): Long {
|
||||
return timestamp + TimeUnit.SECONDS.toMillis(LIFESPAN_SECONDS)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val LIFESPAN_SECONDS = 4L
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,7 @@ class WebRtcViewModel(state: WebRtcServiceState) {
|
||||
val isRemoteVideoOffer: Boolean = state.getCallSetupState(state.callInfoState.activePeer?.callId).isRemoteVideoOffer
|
||||
val callConnectedTime: Long = state.callInfoState.callConnectedTime
|
||||
val remoteParticipants: List<CallParticipant> = state.callInfoState.remoteCallParticipants
|
||||
val raisedHands: List<GroupCallRaiseHandEvent> = state.callInfoState.raisedHands
|
||||
val identityChangedParticipants: Set<RecipientId> = state.callInfoState.identityChangedRecipients
|
||||
val remoteDevicesCount: OptionalLong = state.callInfoState.remoteDevicesCount
|
||||
val participantLimit: Long? = state.callInfoState.participantLimit
|
||||
@@ -109,7 +110,8 @@ class WebRtcViewModel(state: WebRtcServiceState) {
|
||||
val localParticipant: CallParticipant = createLocal(
|
||||
state.localDeviceState.cameraState,
|
||||
(if (state.videoState.localSink != null) state.videoState.localSink else BroadcastVideoSink())!!,
|
||||
state.localDeviceState.isMicrophoneEnabled
|
||||
state.localDeviceState.isMicrophoneEnabled,
|
||||
state.callInfoState.raisedHands.map { it.sender }.contains(Recipient.self())
|
||||
)
|
||||
|
||||
val isCellularConnection: Boolean = when (state.localDeviceState.networkConnectionType) {
|
||||
|
||||
@@ -50,6 +50,7 @@ public class BeginCallActionProcessorDelegate extends WebRtcActionProcessor {
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
currentState.getCallInfoState().getRaisedHands().contains(remotePeer.getRecipient()),
|
||||
0,
|
||||
true,
|
||||
0,
|
||||
@@ -108,6 +109,7 @@ public class BeginCallActionProcessorDelegate extends WebRtcActionProcessor {
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
currentState.getCallInfoState().getRaisedHands().contains(remotePeer.getRecipient()),
|
||||
0,
|
||||
true,
|
||||
0,
|
||||
|
||||
@@ -116,6 +116,7 @@ public class GroupActionProcessor extends DeviceAwareActionProcessor {
|
||||
device.getForwardingVideo() == null || device.getForwardingVideo(),
|
||||
Boolean.FALSE.equals(device.getAudioMuted()),
|
||||
Boolean.FALSE.equals(device.getVideoMuted()),
|
||||
currentState.getCallInfoState().getRaisedHands().contains(recipient),
|
||||
device.getSpeakerTime(),
|
||||
device.getMediaKeysReceived(),
|
||||
device.getAddedTime(),
|
||||
|
||||
@@ -14,6 +14,7 @@ import org.signal.ringrtc.GroupCall;
|
||||
import org.signal.ringrtc.PeekInfo;
|
||||
import org.thoughtcrime.securesms.events.CallParticipant;
|
||||
import org.thoughtcrime.securesms.events.CallParticipantId;
|
||||
import org.thoughtcrime.securesms.events.GroupCallRaiseHandEvent;
|
||||
import org.thoughtcrime.securesms.events.GroupCallReactionEvent;
|
||||
import org.thoughtcrime.securesms.events.WebRtcViewModel;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
@@ -26,10 +27,15 @@ import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import ezvcard.util.StringUtils;
|
||||
|
||||
/**
|
||||
* Process actions for when the call has at least once been connected and joined.
|
||||
@@ -202,6 +208,21 @@ public class GroupConnectedActionProcessor extends GroupActionProcessor {
|
||||
return terminateGroupCall(currentState);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected @NonNull WebRtcServiceState handleSelfRaiseHand(@NonNull WebRtcServiceState currentState, boolean raised) {
|
||||
Log.i(tag, "handleSelfRaiseHand():");
|
||||
try {
|
||||
final CallInfoState callInfoState = currentState.getCallInfoState();
|
||||
|
||||
callInfoState.requireGroupCall().raiseHand(raised);
|
||||
|
||||
return currentState;
|
||||
} catch (CallException e) {
|
||||
Log.w(TAG, "Unable to " + (raised ? "raise" : "lower") + " hand in group call", e);
|
||||
}
|
||||
return currentState;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected @NonNull WebRtcEphemeralState handleSendGroupReact(@NonNull WebRtcServiceState currentState, @NonNull WebRtcEphemeralState ephemeralState, @NonNull String reaction) {
|
||||
try {
|
||||
@@ -242,4 +263,37 @@ public class GroupConnectedActionProcessor extends GroupActionProcessor {
|
||||
|
||||
return new GroupCallReactionEvent(participant.getRecipient(), reaction.value, System.currentTimeMillis());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected @NonNull WebRtcServiceState handleGroupCallRaisedHand(@NonNull WebRtcServiceState currentState, List<Long> raisedHands) {
|
||||
Log.i(tag, "handleGroupCallRaisedHand():");
|
||||
List<GroupCallRaiseHandEvent> existingHands = currentState.getCallInfoState().getRaisedHands();
|
||||
|
||||
List<CallParticipant> participants = currentState.getCallInfoState().getRemoteCallParticipants();
|
||||
List<GroupCallRaiseHandEvent> currentRaisedHands = raisedHands
|
||||
.stream().map(demuxId -> {
|
||||
if (Objects.equals(demuxId, currentState.getCallInfoState().requireGroupCall().getLocalDeviceState().getDemuxId())) {
|
||||
return Recipient.self();
|
||||
}
|
||||
|
||||
CallParticipant participant = participants.stream().filter(it -> it.getCallParticipantId().getDemuxId() == demuxId).findFirst().orElse(null);
|
||||
if (participant == null) {
|
||||
Log.v(TAG, "Could not find CallParticipantId in list of call participants based on demuxId for raise hand.");
|
||||
return null;
|
||||
}
|
||||
return participant.getRecipient();
|
||||
})
|
||||
.filter(Objects::nonNull)
|
||||
.map(recipient -> {
|
||||
final Optional<GroupCallRaiseHandEvent> matchingEvent = existingHands.stream().filter(existingEvent -> existingEvent.getSender().equals(recipient)).findFirst();
|
||||
if (matchingEvent.isPresent()) {
|
||||
return matchingEvent.get();
|
||||
} else {
|
||||
return new GroupCallRaiseHandEvent(recipient, System.currentTimeMillis());
|
||||
}
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return currentState.builder().changeCallInfoState().setRaisedHand(currentRaisedHands).build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +137,7 @@ public class GroupPreJoinActionProcessor extends GroupActionProcessor {
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
currentState.getCallInfoState().getRaisedHands().contains(recipient),
|
||||
0,
|
||||
false,
|
||||
0,
|
||||
|
||||
@@ -167,6 +167,7 @@ public final class IncomingGroupCallActionProcessor extends DeviceAwareActionPro
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
currentState.getCallInfoState().getRaisedHands().contains(remotePeerGroup.getRecipient()),
|
||||
0,
|
||||
true,
|
||||
0,
|
||||
|
||||
@@ -295,6 +295,10 @@ public final class SignalCallManager implements CallManager.Observer, GroupCall.
|
||||
process((s, p) -> p.handleScreenOffChange(s));
|
||||
}
|
||||
|
||||
public void raiseHand(boolean raised) {
|
||||
process((s, p) -> p.handleSelfRaiseHand(s, raised));
|
||||
}
|
||||
|
||||
public void react(@NonNull String reaction) {
|
||||
processStateless(s -> serviceState.getActionProcessor().handleSendGroupReact(serviceState, s, reaction));
|
||||
}
|
||||
@@ -909,7 +913,9 @@ public final class SignalCallManager implements CallManager.Observer, GroupCall.
|
||||
|
||||
@Override
|
||||
public void onRaisedHands(@NonNull GroupCall groupCall, List<Long> raisedHands) {
|
||||
// TODO: Implement handling of raise hand.
|
||||
if (FeatureFlags.groupCallRaiseHand()) {
|
||||
process((s, p) -> p.handleGroupCallRaisedHand(s, raisedHands));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -546,6 +546,12 @@ public abstract class WebRtcActionProcessor {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
|
||||
protected @NonNull WebRtcServiceState handleSelfRaiseHand(@NonNull WebRtcServiceState currentState, boolean raised) {
|
||||
Log.i(tag, "raiseHand not processed");
|
||||
return currentState;
|
||||
}
|
||||
|
||||
protected @NonNull WebRtcEphemeralState handleSendGroupReact(@NonNull WebRtcServiceState currentState, @NonNull WebRtcEphemeralState ephemeralState, @NonNull String reaction) {
|
||||
Log.i(tag, "react not processed");
|
||||
return ephemeralState;
|
||||
@@ -739,6 +745,11 @@ public abstract class WebRtcActionProcessor {
|
||||
return ephemeralState;
|
||||
}
|
||||
|
||||
protected @NonNull WebRtcServiceState handleGroupCallRaisedHand(@NonNull WebRtcServiceState currentState, List<Long> raisedHands) {
|
||||
Log.i(tag, "handleGroupCallRaisedHand not processed");
|
||||
return currentState;
|
||||
}
|
||||
|
||||
protected @NonNull WebRtcServiceState handleGroupRequestMembershipProof(@NonNull WebRtcServiceState currentState, int groupCallHashCode) {
|
||||
Log.i(tag, "handleGroupRequestMembershipProof not processed");
|
||||
return currentState;
|
||||
|
||||
@@ -5,6 +5,7 @@ import org.signal.ringrtc.CallId
|
||||
import org.signal.ringrtc.GroupCall
|
||||
import org.thoughtcrime.securesms.events.CallParticipant
|
||||
import org.thoughtcrime.securesms.events.CallParticipantId
|
||||
import org.thoughtcrime.securesms.events.GroupCallRaiseHandEvent
|
||||
import org.thoughtcrime.securesms.events.WebRtcViewModel
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
@@ -30,7 +31,8 @@ data class CallInfoState(
|
||||
var remoteDevicesCount: OptionalLong = OptionalLong.empty(),
|
||||
var participantLimit: Long? = null,
|
||||
var pendingParticipants: PendingParticipantCollection = PendingParticipantCollection(),
|
||||
var callLinkDisconnectReason: CallLinkDisconnectReason? = null
|
||||
var callLinkDisconnectReason: CallLinkDisconnectReason? = null,
|
||||
var raisedHands: List<GroupCallRaiseHandEvent> = emptyList()
|
||||
) {
|
||||
|
||||
val remoteCallParticipants: List<CallParticipant>
|
||||
|
||||
@@ -12,6 +12,7 @@ import org.thoughtcrime.securesms.components.webrtc.BroadcastVideoSink;
|
||||
import org.thoughtcrime.securesms.components.webrtc.EglBaseWrapper;
|
||||
import org.thoughtcrime.securesms.events.CallParticipant;
|
||||
import org.thoughtcrime.securesms.events.CallParticipantId;
|
||||
import org.thoughtcrime.securesms.events.GroupCallRaiseHandEvent;
|
||||
import org.thoughtcrime.securesms.events.WebRtcViewModel;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
@@ -364,5 +365,10 @@ public class WebRtcServiceStateBuilder {
|
||||
toBuild.setCallLinkDisconnectReason(callLinkDisconnectReason);
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NonNull CallInfoStateBuilder setRaisedHand(@NonNull List<GroupCallRaiseHandEvent> raisedHands) {
|
||||
toBuild.setRaisedHands(raisedHands);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +118,7 @@ public final class FeatureFlags {
|
||||
public static final String SEPA_ENABLED_REGIONS = "global.donations.sepaEnabledRegions";
|
||||
private static final String CALLING_REACTIONS = "android.calling.reactions";
|
||||
private static final String NOTIFICATION_THUMBNAIL_BLOCKLIST = "android.notificationThumbnailProductBlocklist";
|
||||
private static final String CALLING_RAISE_HAND = "android.calling.raiseHand";
|
||||
|
||||
/**
|
||||
* We will only store remote values for flags in this set. If you want a flag to be controllable
|
||||
@@ -187,7 +188,8 @@ public final class FeatureFlags {
|
||||
IDEAL_ENABLED_REGIONS,
|
||||
SEPA_ENABLED_REGIONS,
|
||||
CALLING_REACTIONS,
|
||||
NOTIFICATION_THUMBNAIL_BLOCKLIST
|
||||
NOTIFICATION_THUMBNAIL_BLOCKLIST,
|
||||
CALLING_RAISE_HAND
|
||||
);
|
||||
|
||||
@VisibleForTesting
|
||||
@@ -259,7 +261,8 @@ public final class FeatureFlags {
|
||||
CRASH_PROMPT_CONFIG,
|
||||
BLOCK_SSE,
|
||||
CALLING_REACTIONS,
|
||||
NOTIFICATION_THUMBNAIL_BLOCKLIST
|
||||
NOTIFICATION_THUMBNAIL_BLOCKLIST,
|
||||
CALLING_RAISE_HAND
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -672,6 +675,13 @@ public final class FeatureFlags {
|
||||
return getBoolean(CALLING_REACTIONS, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether or not group call raise hand is enabled.
|
||||
*/
|
||||
public static boolean groupCallRaiseHand() {
|
||||
return getBoolean(CALLING_RAISE_HAND, false);
|
||||
}
|
||||
|
||||
/** List of device products that are blocked from showing notification thumbnails. */
|
||||
public static String notificationThumbnailProductBlocklist() {
|
||||
return getString(NOTIFICATION_THUMBNAIL_BLOCKLIST, "");
|
||||
|
||||
Reference in New Issue
Block a user