Initial raise hand support.
This commit is contained in:
parent
f2a7824168
commit
c2f5a6390e
28 changed files with 577 additions and 23 deletions
|
@ -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, "");
|
||||
|
|
9
app/src/main/res/drawable/symbol_raise_hand_24.xml
Normal file
9
app/src/main/res/drawable/symbol_raise_hand_24.xml
Normal file
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M20.3 16.1c-0.54 3.83-3.82 6.77-7.8 6.77-3.2 0-5.94-1.9-7.18-4.63l-3.3-6.21-0.09-0.17C1.55 11 1.86 9.99 2.66 9.5l0.16-0.1 0.04-0.02 0.3-0.15c1.22-0.55 2.66-0.17 3.47 0.9V5c0-1.31 1.06-2.38 2.37-2.38 0.22 0 0.44 0.04 0.64 0.1 0.14-1.18 1.15-2.1 2.36-2.1 1.05 0 1.94 0.68 2.25 1.63 0.24-0.08 0.49-0.13 0.75-0.13 1.31 0 2.38 1.07 2.38 2.38v0.2c0.2-0.05 0.4-0.08 0.62-0.08 1.31 0 2.38 1.07 2.38 2.38v8.5c0 0.2-0.03 0.4-0.08 0.6ZM9.63 5c0-0.35-0.28-0.63-0.63-0.63S8.38 4.66 8.38 5v9H6.75L5.4 11.48l-0.11-0.2c-0.3-0.49-0.9-0.67-1.42-0.44l-0.21 0.11-0.07 0.04H3.57c-0.05 0.04-0.07 0.1-0.04 0.16v0.01l0.04 0.07 3.18 5.98v0.01l0.17 0.32c0.96 2.12 3.1 3.6 5.58 3.6 3.21 0 5.85-2.48 6.1-5.63h0.03V7c0-0.35-0.28-0.63-0.63-0.63S17.38 6.66 17.38 7v4.5h-1.75v-7c0-0.35-0.28-0.63-0.63-0.63s-0.62 0.28-0.62 0.63v7h-1.75V3c0-0.35-0.28-0.63-0.63-0.63S11.38 2.66 11.38 3v8.5H9.63V5Z"/>
|
||||
</vector>
|
|
@ -3,10 +3,50 @@
|
|||
~ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<org.thoughtcrime.securesms.components.webrtc.CallReactionScrubber
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/conversation_reaction_overlay_background"
|
||||
android:elevation="4dp"
|
||||
/>
|
||||
android:orientation="vertical">
|
||||
|
||||
<org.thoughtcrime.securesms.components.webrtc.CallReactionScrubber
|
||||
android:id="@+id/reaction_scrubber"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="@dimen/calling_reaction_emoji_height"
|
||||
android:background="@drawable/conversation_reaction_overlay_background"
|
||||
android:elevation="4dp" />
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:id="@+id/raise_hand_layout_parent"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="@dimen/calling_reaction_emoji_height"
|
||||
android:layout_marginTop="8dp"
|
||||
android:background="@drawable/conversation_reaction_overlay_background">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/raise_hand_icon"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:contentDescription="@string/CallOverflowPopupWindow__raise_hand_illustration_content_description"
|
||||
android:src="@drawable/symbol_raise_hand_24"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:tint="@color/signal_colorOnSurface" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/raise_hand_label"
|
||||
style="@style/Signal.Text.BodyLarge"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:text="@string/CallOverflowPopupWindow__raise_hand"
|
||||
android:textColor="@color/signal_colorOnSurface"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/raise_hand_icon"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</LinearLayout>
|
|
@ -21,6 +21,7 @@
|
|||
android:orientation="horizontal"
|
||||
tools:layout_constraintGuide_end="200dp" />
|
||||
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:id="@+id/call_screen_participants_parent"
|
||||
android:layout_width="0dp"
|
||||
|
@ -396,5 +397,12 @@
|
|||
app:barrierDirection="top"
|
||||
app:constraint_referenced_ids="call_screen_answer_call,call_screen_decline_call,call_screen_audio_mic_toggle,call_screen_camera_direction_toggle,call_screen_video_toggle,call_screen_answer_without_video,call_screen_speaker_toggle,call_screen_end_call" />
|
||||
|
||||
<androidx.compose.ui.platform.ComposeView
|
||||
android:id="@+id/call_screen_raise_hand_view"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintBottom_toTopOf="@id/call_screen_above_controls_guideline"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
|
||||
<include layout="@layout/webrtc_call_controls"/>
|
||||
</merge>
|
|
@ -79,9 +79,15 @@
|
|||
<dimen name="reaction_scrubber_anim_start_translation_y">25dp</dimen>
|
||||
<dimen name="reaction_scrubber_anim_end_translation_y">0dp</dimen>
|
||||
<dimen name="reaction_scrubber_width">320dp</dimen>
|
||||
|
||||
<dimen name="calling_reaction_scrubber_margin">4dp</dimen>
|
||||
<dimen name="calling_reaction_emoji_height">48dp</dimen>
|
||||
|
||||
<dimen name="calling_reaction_popup_menu_width">320dp</dimen>
|
||||
<dimen name="calling_reaction_popup_menu_height">120dp</dimen>
|
||||
|
||||
<dimen name="calling_raise_hand_snackbar_margin">16dp</dimen>
|
||||
|
||||
<dimen name="conversation_item_reply_size">38dp</dimen>
|
||||
<dimen name="conversation_item_avatar_size">28dp</dimen>
|
||||
|
||||
|
|
|
@ -1849,6 +1849,34 @@
|
|||
<!-- A text description of the earpiece icon, used for accessibility. -->
|
||||
<string name="WebRtcAudioOutputBottomSheet__earpiece_icon_content_description">An icon representing a device\'s earpiece.</string>
|
||||
|
||||
<!-- A clickable button to "raise your hand" in a group call to signify you have something to say -->
|
||||
<string name="CallOverflowPopupWindow__raise_hand">Raise hand</string>
|
||||
<!-- A description of a clickable image representing a raised hand -->
|
||||
<string name="CallOverflowPopupWindow__raise_hand_illustration_content_description">Raise hand</string>
|
||||
<!-- A dialog prompt to confirm you want to lower your hand -->
|
||||
<string name="CallOverflowPopupWindow__lower_your_hand">Lower your hand?</string>
|
||||
<!-- A dialog button to confirm you would like to lower your hand -->
|
||||
<string name="CallOverflowPopupWindow__lower_hand">Lower hand</string>
|
||||
<!-- A notification to the user that they successfully raised their hand -->
|
||||
<string name="CallOverflowPopupWindow__you_raised_your_hand">You raised your hand</string>
|
||||
<!-- A button to take you to a list of participants with raised hands -->
|
||||
<string name="CallOverflowPopupWindow__view">View</string>
|
||||
|
||||
<!-- A notification to the user that one or more participants in the call successfully raised their hand. In the singular case, it is a name. In the plural case, it is a name or "You" -->
|
||||
<plurals name="CallOverflowPopupWindow__raised_a_hand">
|
||||
<item quantity="one">%1$s has raised a hand</item>
|
||||
<item quantity="other">%1$s + %2$d have raised a hand</item>
|
||||
</plurals>
|
||||
|
||||
<!-- A badge to show how many hands are raised. The first string may be a name or "You" -->
|
||||
<plurals name="CallRaiseHandSnackbar_raised_hands">
|
||||
<item quantity="one">%1$s</item>
|
||||
<item quantity="other">%1$s +%2$d</item>
|
||||
</plurals>
|
||||
|
||||
<!-- An accessibility label for screen readers on a view that can be expanded -->
|
||||
<string name="CallOverflowPopupWindow__expand_snackbar_accessibility_label">Expand raised hand view</string>
|
||||
|
||||
<!-- CallParticipantsListDialog -->
|
||||
<plurals name="CallParticipantsListDialog_in_this_call">
|
||||
<item quantity="one">In this call (%1$d)</item>
|
||||
|
@ -1862,6 +1890,10 @@
|
|||
<item quantity="one">Signal will Notify (%1$d)</item>
|
||||
<item quantity="other">Signal will Notify (%1$d)</item>
|
||||
</plurals>
|
||||
<plurals name="CallParticipantsListDialog__raised_hands">
|
||||
<item quantity="one">Raised hand (%1$d)</item>
|
||||
<item quantity="other">Raised hands (%1$d)</item>
|
||||
</plurals>
|
||||
|
||||
<!-- CallParticipantView -->
|
||||
<string name="CallParticipantView__s_is_blocked">%1$s is blocked</string>
|
||||
|
|
|
@ -178,7 +178,7 @@ public class CallParticipantListUpdateTest {
|
|||
private static CallParticipant createParticipant(long recipientId, long deMuxId, @NonNull CallParticipant.DeviceOrdinal deviceOrdinal) {
|
||||
Recipient recipient = new Recipient(RecipientId.from(recipientId), mock(RecipientDetails.class), true);
|
||||
|
||||
return CallParticipant.createRemote(new CallParticipantId(deMuxId, recipient.getId()), recipient, null, new BroadcastVideoSink(), false, false, false, -1, false, 0, false, deviceOrdinal);
|
||||
return CallParticipant.createRemote(new CallParticipantId(deMuxId, recipient.getId()), recipient, null, new BroadcastVideoSink(), false, false, false, false, -1, false, 0, false, deviceOrdinal);
|
||||
}
|
||||
|
||||
}
|
|
@ -243,6 +243,7 @@ public class ParticipantCollectionTest {
|
|||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
lastSpoke,
|
||||
false,
|
||||
added,
|
||||
|
|
Loading…
Add table
Reference in a new issue