Add support for remote muting call participants.

This commit is contained in:
Greyson Parrelli
2026-03-04 10:55:02 -05:00
parent 7266c24354
commit b054a30fa7
12 changed files with 617 additions and 30 deletions

View File

@@ -69,6 +69,7 @@ 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
import org.thoughtcrime.securesms.util.RemoteConfig
/**
* Renders information about a call (1:1, group, or call link) and provides actions available for
@@ -114,6 +115,12 @@ object CallInfoView {
onShareLinkClicked = callbacks::onShareLinkClicked,
onEditNameClicked = onEditNameClicked,
onBlock = callbacks::onBlock,
onMuteAudio = callbacks::onMuteAudio,
onRemoveFromCall = callbacks::onRemoveFromCall,
onContactDetails = callbacks::onContactDetails,
onViewSafetyNumber = callbacks::onViewSafetyNumber,
onGoToChat = callbacks::onGoToChat,
isInternalUser = RemoteConfig.internalUser,
modifier = modifier
)
}
@@ -122,6 +129,11 @@ object CallInfoView {
fun onShareLinkClicked()
fun onEditNameClicked(name: String)
fun onBlock(callParticipant: CallParticipant)
fun onMuteAudio(callParticipant: CallParticipant)
fun onRemoveFromCall(callParticipant: CallParticipant)
fun onContactDetails(callParticipant: CallParticipant)
fun onViewSafetyNumber(callParticipant: CallParticipant)
fun onGoToChat(callParticipant: CallParticipant)
}
}
@@ -135,7 +147,12 @@ private fun CallInfoPreview() {
controlAndInfoState = ControlAndInfoState(),
onShareLinkClicked = { },
onEditNameClicked = { },
onBlock = { }
onBlock = { },
onMuteAudio = { },
onRemoveFromCall = { },
onContactDetails = { },
onViewSafetyNumber = { },
onGoToChat = { }
)
}
}
@@ -147,8 +164,15 @@ private fun CallInfo(
onShareLinkClicked: () -> Unit,
onEditNameClicked: () -> Unit,
onBlock: (CallParticipant) -> Unit,
onMuteAudio: (CallParticipant) -> Unit = {},
onRemoveFromCall: (CallParticipant) -> Unit = {},
onContactDetails: (CallParticipant) -> Unit = {},
onViewSafetyNumber: (CallParticipant) -> Unit = {},
onGoToChat: (CallParticipant) -> Unit = {},
isInternalUser: Boolean = false,
modifier: Modifier = Modifier
) {
var selectedParticipant by remember { mutableStateOf<CallParticipant?>(null) }
val listState = rememberLazyListState()
LaunchedEffect(controlAndInfoState.resetScrollState) {
@@ -252,7 +276,16 @@ private fun CallInfo(
CallParticipantRow(
callParticipant = it,
isSelfAdmin = controlAndInfoState.isSelfAdmin() && !participantsState.inCallLobby,
onBlockClicked = onBlock
onBlockClicked = onBlock,
onParticipantClicked = if (isInternalUser) {
{ participant ->
if (!participant.recipient.isSelf) {
selectedParticipant = participant
}
}
} else {
null
}
)
}
@@ -312,6 +345,20 @@ private fun CallInfo(
Spacer(modifier = Modifier.size(48.dp))
}
}
selectedParticipant?.let { participant ->
ParticipantActionsSheet(
callParticipant = participant,
isSelfAdmin = controlAndInfoState.isSelfAdmin(),
isCallLink = controlAndInfoState.callLink != null,
onDismiss = { selectedParticipant = null },
onMuteAudio = onMuteAudio,
onRemoveFromCall = onRemoveFromCall,
onContactDetails = onContactDetails,
onViewSafetyNumber = onViewSafetyNumber,
onGoToChat = onGoToChat
)
}
}
@Composable
@@ -336,9 +383,10 @@ private fun CallParticipantRowPreview() {
Previews.Preview {
Surface {
CallParticipantRow(
CallParticipant(recipient = Recipient(isResolving = false, systemContactName = "Miles Morales")),
isSelfAdmin = true
) {}
callParticipant = CallParticipant(recipient = Recipient(isResolving = false, systemContactName = "Miles Morales")),
isSelfAdmin = true,
onBlockClicked = {}
)
}
}
}
@@ -357,7 +405,8 @@ private fun HandRaisedRowPreview() {
private fun CallParticipantRow(
callParticipant: CallParticipant,
isSelfAdmin: Boolean,
onBlockClicked: (CallParticipant) -> Unit
onBlockClicked: (CallParticipant) -> Unit,
onParticipantClicked: ((CallParticipant) -> Unit)? = null
) {
CallParticipantRow(
initialRecipient = callParticipant.recipient,
@@ -368,7 +417,12 @@ private fun CallParticipantRow(
showHandRaised = false,
canLowerHand = false,
isSelfAdmin = isSelfAdmin,
onBlockClicked = { onBlockClicked(callParticipant) }
onBlockClicked = { onBlockClicked(callParticipant) },
onRowClicked = if (onParticipantClicked != null && !callParticipant.recipient.isSelf) {
{ onParticipantClicked(callParticipant) }
} else {
null
}
)
}
@@ -396,14 +450,22 @@ private fun CallParticipantRow(
isMicrophoneEnabled: Boolean,
showHandRaised: Boolean,
canLowerHand: Boolean,
isSelfAdmin: Boolean,
onBlockClicked: () -> Unit
isSelfAdmin: Boolean = false,
onBlockClicked: () -> Unit = {},
onRowClicked: (() -> Unit)? = null
) {
Row(
modifier = Modifier
val rowModifier = if (onRowClicked != null) {
Modifier
.fillMaxWidth()
.clickable(onClick = onRowClicked)
.padding(Rows.defaultPadding())
} else {
Modifier
.fillMaxWidth()
.padding(Rows.defaultPadding())
) {
}
Row(modifier = rowModifier) {
val recipient by ((if (LocalInspectionMode.current) Observable.just(Recipient.UNKNOWN) else Recipient.observable(initialRecipient.id)))
.toFlowable(BackpressureStrategy.LATEST)
.toLiveData()
@@ -512,8 +574,9 @@ private fun GroupMemberRow(
isMicrophoneEnabled = false,
showHandRaised = false,
canLowerHand = false,
isSelfAdmin = isSelfAdmin
) {}
isSelfAdmin = isSelfAdmin,
onBlockClicked = {}
)
}
@Composable

View File

@@ -11,9 +11,10 @@ import org.thoughtcrime.securesms.database.CallLinkTable
@Immutable
data class ControlAndInfoState(
val callLink: CallLinkTable.CallLink? = null,
val isGroupAdmin: Boolean = false,
val resetScrollState: Long = 0
) {
fun isSelfAdmin(): Boolean {
return callLink?.credentials?.adminPassBytes != null
return callLink?.credentials?.adminPassBytes != null || isGroupAdmin
}
}

View File

@@ -13,9 +13,12 @@ import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.calls.links.CallLinks
import org.thoughtcrime.securesms.calls.links.UpdateCallLinkRepository
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsRepository
import org.thoughtcrime.securesms.database.GroupTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
@@ -42,6 +45,16 @@ class ControlsAndInfoViewModel(
disposables += CallLinks.watchCallLink(recipient.requireCallLinkRoomId()).subscribeBy {
_state.value = _state.value.copy(callLink = it)
}
} else if (recipient.isGroup && callRecipientId != recipient.id) {
callRecipientId = recipient.id
disposables += Single.fromCallable {
val groupRecord = SignalDatabase.groups.getGroup(recipient.requireGroupId())
groupRecord.isPresent && groupRecord.get().memberLevel(Recipient.self()) == GroupTable.MemberLevel.ADMINISTRATOR
}
.subscribeOn(Schedulers.io())
.subscribeBy { isAdmin ->
_state.value = _state.value.copy(isGroupAdmin = isAdmin)
}
}
}

View File

@@ -0,0 +1,235 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.webrtc.controls
import androidx.compose.foundation.layout.Column
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.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.toLiveData
import io.reactivex.rxjava3.core.BackpressureStrategy
import io.reactivex.rxjava3.core.Observable
import org.signal.core.ui.compose.AllNightPreviews
import org.signal.core.ui.compose.Dividers
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Rows
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.events.CallParticipant
import org.thoughtcrime.securesms.recipients.Recipient
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ParticipantActionsSheet(
callParticipant: CallParticipant,
isSelfAdmin: Boolean,
isCallLink: Boolean,
onDismiss: () -> Unit,
onMuteAudio: (CallParticipant) -> Unit,
onRemoveFromCall: (CallParticipant) -> Unit,
onContactDetails: (CallParticipant) -> Unit,
onViewSafetyNumber: (CallParticipant) -> Unit,
onGoToChat: (CallParticipant) -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState
) {
val recipient by (
(if (LocalInspectionMode.current) Observable.just(Recipient.UNKNOWN) else Recipient.observable(callParticipant.recipient.id))
.toFlowable(BackpressureStrategy.LATEST)
.toLiveData()
.observeAsState(initial = callParticipant.recipient)
)
ParticipantActionsSheetContent(
recipient = recipient,
callParticipant = callParticipant,
isSelfAdmin = isSelfAdmin,
isCallLink = isCallLink,
onDismiss = onDismiss,
onMuteAudio = onMuteAudio,
onRemoveFromCall = onRemoveFromCall,
onContactDetails = onContactDetails,
onViewSafetyNumber = onViewSafetyNumber,
onGoToChat = onGoToChat
)
}
}
@Composable
private fun ParticipantActionsSheetContent(
recipient: Recipient,
callParticipant: CallParticipant,
isSelfAdmin: Boolean,
isCallLink: Boolean,
onDismiss: () -> Unit,
onMuteAudio: (CallParticipant) -> Unit,
onRemoveFromCall: (CallParticipant) -> Unit,
onContactDetails: (CallParticipant) -> Unit,
onViewSafetyNumber: (CallParticipant) -> Unit,
onGoToChat: (CallParticipant) -> Unit
) {
ParticipantHeader(recipient = recipient)
val hasAdminActions = isSelfAdmin && (callParticipant.isMicrophoneEnabled || isCallLink)
if (hasAdminActions) {
Dividers.Default()
if (callParticipant.isMicrophoneEnabled) {
Rows.TextRow(
text = stringResource(id = R.string.CallParticipantSheet__mute_audio),
icon = painterResource(id = R.drawable.symbol_mic_slash_24),
onClick = {
onMuteAudio(callParticipant)
onDismiss()
}
)
}
if (isCallLink) {
Rows.TextRow(
text = stringResource(id = R.string.CallParticipantSheet__remove_from_call),
icon = painterResource(id = R.drawable.symbol_minus_circle_24),
onClick = {
onRemoveFromCall(callParticipant)
onDismiss()
}
)
}
}
Dividers.Default()
Rows.TextRow(
text = stringResource(id = R.string.CallParticipantSheet__contact_details),
icon = painterResource(id = R.drawable.symbol_person_24),
onClick = {
onContactDetails(callParticipant)
onDismiss()
}
)
Rows.TextRow(
text = stringResource(id = R.string.ConversationSettingsFragment__view_safety_number),
icon = painterResource(id = R.drawable.symbol_safety_number_24),
onClick = {
onViewSafetyNumber(callParticipant)
onDismiss()
}
)
Rows.TextRow(
text = stringResource(id = R.string.CallContextMenu__go_to_chat),
icon = painterResource(id = R.drawable.symbol_open_24),
onClick = {
onGoToChat(callParticipant)
onDismiss()
}
)
Spacer(modifier = Modifier.size(48.dp))
}
@Composable
private fun ParticipantHeader(recipient: Recipient) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp)
) {
if (LocalInspectionMode.current) {
Spacer(modifier = Modifier.size(64.dp))
} else {
AndroidView(
factory = ::AvatarImageView,
modifier = Modifier.size(64.dp)
) {
it.setAvatarUsingProfile(recipient)
}
}
Spacer(modifier = Modifier.size(12.dp))
Text(
text = recipient.getDisplayName(androidx.compose.ui.platform.LocalContext.current),
style = MaterialTheme.typography.titleLarge
)
val e164 = recipient.e164
if (e164.isPresent) {
Spacer(modifier = Modifier.size(2.dp))
Text(
text = e164.get(),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@AllNightPreviews
@Composable
private fun ParticipantActionsSheetAdminPreview() {
Previews.BottomSheetPreview {
ParticipantActionsSheetContent(
recipient = Recipient(isResolving = false, systemContactName = "Peter Parker"),
callParticipant = CallParticipant(
recipient = Recipient(isResolving = false, systemContactName = "Peter Parker"),
isMicrophoneEnabled = true
),
isSelfAdmin = true,
isCallLink = true,
onDismiss = {},
onMuteAudio = {},
onRemoveFromCall = {},
onContactDetails = {},
onViewSafetyNumber = {},
onGoToChat = {}
)
}
}
@AllNightPreviews
@Composable
private fun ParticipantActionsSheetNonAdminPreview() {
Previews.BottomSheetPreview {
ParticipantActionsSheetContent(
recipient = Recipient(isResolving = false, systemContactName = "Gwen Stacy"),
callParticipant = CallParticipant(
recipient = Recipient(isResolving = false, systemContactName = "Gwen Stacy")
),
isSelfAdmin = false,
isCallLink = false,
onDismiss = {},
onMuteAudio = {},
onRemoveFromCall = {},
onContactDetails = {},
onViewSafetyNumber = {},
onGoToChat = {}
)
}
}

View File

@@ -15,10 +15,13 @@ import org.thoughtcrime.securesms.BaseActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.links.CallLinks
import org.thoughtcrime.securesms.calls.links.EditCallLinkNameDialogFragment
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity
import org.thoughtcrime.securesms.components.webrtc.controls.CallInfoView
import org.thoughtcrime.securesms.components.webrtc.controls.ControlsAndInfoViewModel
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.events.CallParticipant
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.verify.VerifyIdentityActivity
/**
* Callbacks for the CallInfoView, shared between CallActivity and ControlsAndInfoController.
@@ -60,4 +63,34 @@ class CallInfoCallbacks(
}
.show()
}
override fun onMuteAudio(callParticipant: CallParticipant) {
AppDependencies.signalCallManager.sendRemoteMuteRequest(callParticipant)
}
override fun onRemoveFromCall(callParticipant: CallParticipant) {
MaterialAlertDialogBuilder(activity)
.setNegativeButton(android.R.string.cancel, null)
.setMessage(activity.resources.getString(R.string.CallLinkInfoSheet__remove_s_from_the_call, callParticipant.recipient.getShortDisplayName(activity)))
.setPositiveButton(R.string.CallLinkInfoSheet__remove) { _, _ ->
AppDependencies.signalCallManager.removeFromCallLink(callParticipant)
}
.setNeutralButton(R.string.CallLinkInfoSheet__block_from_call) { _, _ ->
AppDependencies.signalCallManager.blockFromCallLink(callParticipant)
}
.show()
}
override fun onContactDetails(callParticipant: CallParticipant) {
activity.startActivity(ConversationSettingsActivity.forRecipient(activity, callParticipant.recipient.id))
}
override fun onViewSafetyNumber(callParticipant: CallParticipant) {
val identityRecord = AppDependencies.protocolStore.aci().identities().getIdentityRecord(callParticipant.recipient.id)
VerifyIdentityActivity.startOrShowExchangeMessagesDialog(activity, identityRecord.orElse(null))
}
override fun onGoToChat(callParticipant: CallParticipant) {
CommunicationActions.startConversation(activity, callParticipant.recipient, null)
}
}

View File

@@ -5,6 +5,7 @@
package org.thoughtcrime.securesms.components.webrtc.v2
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.displayCutoutPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.statusBarsPadding
@@ -15,7 +16,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.movableContentOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import org.signal.core.ui.compose.AllNightPreviews
import org.signal.core.ui.compose.Previews
import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette
@@ -28,12 +31,17 @@ import org.thoughtcrime.securesms.recipients.RecipientId
fun CallParticipantsPager(
callParticipantsPagerState: CallParticipantsPagerState,
pagerState: PagerState,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
onTap: (() -> Unit)? = null,
onParticipantLongPress: ((CallParticipant) -> Unit)? = null
) {
if (callParticipantsPagerState.focusedParticipant == null) {
return
}
val currentOnTap = rememberUpdatedState(onTap)
val currentOnLongPress = rememberUpdatedState(onParticipantLongPress)
val firstParticipantAR = rememberParticipantAspectRatio(
callParticipantsPagerState.callParticipants.firstOrNull()?.videoSink
)
@@ -48,13 +56,24 @@ fun CallParticipantsPager(
modifier = mod,
itemKey = { it.callParticipantId }
) { participant, itemModifier ->
val longPressModifier = if (!participant.recipient.isSelf && currentOnLongPress.value != null) {
itemModifier.pointerInput(participant.callParticipantId) {
detectTapGestures(
onTap = { currentOnTap.value?.invoke() },
onLongPress = { currentOnLongPress.value?.invoke(participant) }
)
}
} else {
itemModifier
}
RemoteParticipantContent(
participant = participant,
renderInPip = state.isRenderInPip,
raiseHandAllowed = false,
onInfoMoreInfoClick = null,
showAudioIndicator = state.callParticipants.size > 1,
modifier = itemModifier
modifier = longPressModifier
)
}
}

View File

@@ -14,6 +14,7 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
@@ -25,7 +26,10 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.BottomSheetScaffold
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SheetValue
@@ -35,6 +39,7 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
@@ -48,6 +53,8 @@ import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -57,6 +64,7 @@ import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.TriggerAlignedPopupState
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.emoji.EmojiStrings
import org.thoughtcrime.securesms.components.webrtc.WebRtcLocalRenderState
import org.thoughtcrime.securesms.components.webrtc.controls.RaiseHandSnackbar
@@ -118,7 +126,15 @@ fun CallScreen(
onCallScreenDialogDismissed: () -> Unit = {},
onWifiToCellularPopupDismissed: () -> Unit = {},
onSwipeToSpeakerHintDismissed: () -> Unit = {},
onRemoteMuteToastDismissed: () -> Unit = {}
onRemoteMuteToastDismissed: () -> Unit = {},
isInternalUser: Boolean = false,
isSelfAdmin: Boolean = false,
isCallLink: Boolean = false,
onMuteAudio: (CallParticipant) -> Unit = {},
onRemoveFromCall: (CallParticipant) -> Unit = {},
onContactDetails: (CallParticipant) -> Unit = {},
onViewSafetyNumber: (CallParticipant) -> Unit = {},
onGoToChat: (CallParticipant) -> Unit = {}
) {
if (webRtcCallState == WebRtcViewModel.State.CALL_INCOMING) {
IncomingCallScreen(
@@ -312,22 +328,53 @@ fun CallScreen(
)
}
} else if (webRtcCallState.isPassedPreJoin) {
var longPressedParticipantId by remember { mutableStateOf<CallParticipantId?>(null) }
val longPressedParticipant = longPressedParticipantId?.let { id ->
callParticipantsPagerState.callParticipants.find { it.callParticipantId == id }
}
CallElementsLayout(
callGridSlot = {
CallParticipantsPager(
callParticipantsPagerState = callParticipantsPagerState,
pagerState = callScreenController.callParticipantsVerticalPagerState,
modifier = Modifier
.fillMaxSize()
.clickable(
onClick = {
Box {
CallParticipantsPager(
callParticipantsPagerState = callParticipantsPagerState,
pagerState = callScreenController.callParticipantsVerticalPagerState,
modifier = Modifier
.fillMaxSize()
.clickable(
onClick = {
scope.launch {
callScreenController.handleEvent(CallScreenController.Event.TOGGLE_CONTROLS)
}
},
enabled = !callControlsState.skipHiddenState
),
onTap = {
if (!callControlsState.skipHiddenState) {
scope.launch {
callScreenController.handleEvent(CallScreenController.Event.TOGGLE_CONTROLS)
}
},
enabled = !callControlsState.skipHiddenState
)
)
}
},
onParticipantLongPress = if (isInternalUser) {
{ participant -> longPressedParticipantId = participant.callParticipantId }
} else {
null
}
)
ParticipantContextMenu(
participant = longPressedParticipant,
isSelfAdmin = isSelfAdmin,
isCallLink = isCallLink,
onDismiss = { longPressedParticipantId = null },
onMuteAudio = onMuteAudio,
onRemoveFromCall = onRemoveFromCall,
onContactDetails = onContactDetails,
onViewSafetyNumber = onViewSafetyNumber,
onGoToChat = onGoToChat
)
}
},
pictureInPictureSlot = {
MoveableLocalVideoRenderer(
@@ -518,6 +565,140 @@ private fun AnimatedCallStateUpdate(
}
}
@Composable
private fun ParticipantContextMenu(
participant: CallParticipant?,
isSelfAdmin: Boolean,
isCallLink: Boolean,
onDismiss: () -> Unit,
onMuteAudio: (CallParticipant) -> Unit,
onRemoveFromCall: (CallParticipant) -> Unit,
onContactDetails: (CallParticipant) -> Unit,
onViewSafetyNumber: (CallParticipant) -> Unit,
onGoToChat: (CallParticipant) -> Unit
) {
DropdownMenu(
expanded = participant != null,
onDismissRequest = onDismiss
) {
val resolved = participant ?: return@DropdownMenu
DropdownMenuItem(
text = {
Text(
text = resolved.recipient.getShortDisplayName(androidx.compose.ui.platform.LocalContext.current),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface
)
},
onClick = {},
enabled = false
)
// Divider (default divider has too much padding)
Box(
Modifier
.fillMaxWidth()
.height(1.5.dp)
.background(color = MaterialTheme.colorScheme.surfaceVariant)
)
if (isSelfAdmin && resolved.isMicrophoneEnabled) {
DropdownMenuItem(
text = { Text(stringResource(R.string.CallParticipantSheet__mute_audio)) },
leadingIcon = { Icon(painter = painterResource(R.drawable.symbol_mic_slash_24), contentDescription = null) },
onClick = {
onMuteAudio(resolved)
onDismiss()
}
)
}
if (isSelfAdmin && isCallLink) {
DropdownMenuItem(
text = { Text(stringResource(R.string.CallParticipantSheet__remove_from_call)) },
leadingIcon = { Icon(painter = painterResource(R.drawable.symbol_minus_circle_24), contentDescription = null) },
onClick = {
onRemoveFromCall(resolved)
onDismiss()
}
)
}
DropdownMenuItem(
text = { Text(stringResource(R.string.CallParticipantSheet__contact_details)) },
leadingIcon = { Icon(painter = painterResource(R.drawable.symbol_person_24), contentDescription = null) },
onClick = {
onContactDetails(resolved)
onDismiss()
}
)
DropdownMenuItem(
text = { Text(stringResource(R.string.ConversationSettingsFragment__view_safety_number)) },
leadingIcon = { Icon(painter = painterResource(R.drawable.symbol_safety_number_24), contentDescription = null) },
onClick = {
onViewSafetyNumber(resolved)
onDismiss()
}
)
DropdownMenuItem(
text = { Text(stringResource(R.string.CallContextMenu__go_to_chat)) },
leadingIcon = { Icon(painter = painterResource(R.drawable.symbol_open_24), contentDescription = null) },
onClick = {
onGoToChat(resolved)
onDismiss()
}
)
}
}
@AllNightPreviews
@Composable
private fun ParticipantContextMenuAdminPreview() {
Previews.Preview {
Box {
ParticipantContextMenu(
participant = CallParticipant(
recipient = Recipient(isResolving = false, systemContactName = "Peter Parker"),
isMicrophoneEnabled = true
),
isSelfAdmin = true,
isCallLink = true,
onDismiss = {},
onMuteAudio = {},
onRemoveFromCall = {},
onContactDetails = {},
onViewSafetyNumber = {},
onGoToChat = {}
)
}
}
}
@AllNightPreviews
@Composable
private fun ParticipantContextMenuNonAdminPreview() {
Previews.Preview {
Box {
ParticipantContextMenu(
participant = CallParticipant(
recipient = Recipient(isResolving = false, systemContactName = "Gwen Stacy")
),
isSelfAdmin = false,
isCallLink = false,
onDismiss = {},
onMuteAudio = {},
onRemoveFromCall = {},
onContactDetails = {},
onViewSafetyNumber = {},
onGoToChat = {}
)
}
}
}
@AllNightPreviews
@Composable
private fun CallScreenPreview() {

View File

@@ -49,6 +49,7 @@ import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiBottomSheetDial
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
import org.thoughtcrime.securesms.service.webrtc.state.WebRtcEphemeralState
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.WindowUtil
import org.thoughtcrime.securesms.webrtc.CallParticipantsViewState
import kotlin.time.Duration.Companion.seconds
@@ -173,6 +174,8 @@ class ComposeCallScreenMediator(private val activity: WebRtcCallActivity, viewMo
}
}
val controlAndInfoState by controlsAndInfoViewModel.state
SignalTheme(isDarkMode = true) {
CallScreen(
callRecipient = recipient,
@@ -217,7 +220,15 @@ class ComposeCallScreenMediator(private val activity: WebRtcCallActivity, viewMo
onWifiToCellularPopupDismissed = { callScreenViewModel.callScreenState.update { it.copy(displayWifiToCellularPopup = false) } },
onSwipeToSpeakerHintDismissed = { callScreenViewModel.callScreenState.update { it.copy(displaySwipeToSpeakerHint = false) } },
onRemoteMuteToastDismissed = { callScreenViewModel.callScreenState.update { it.copy(remoteMuteToastMessage = null) } },
callParticipantUpdatePopupController = callParticipantUpdatePopupController
callParticipantUpdatePopupController = callParticipantUpdatePopupController,
isInternalUser = RemoteConfig.internalUser,
isSelfAdmin = controlAndInfoState.isSelfAdmin(),
isCallLink = controlAndInfoState.callLink != null,
onMuteAudio = callInfoCallbacks::onMuteAudio,
onRemoveFromCall = callInfoCallbacks::onRemoveFromCall,
onContactDetails = callInfoCallbacks::onContactDetails,
onViewSafetyNumber = callInfoCallbacks::onViewSafetyNumber,
onGoToChat = callInfoCallbacks::onGoToChat
)
}
}

View File

@@ -366,6 +366,19 @@ public class GroupConnectedActionProcessor extends GroupActionProcessor {
return currentState;
}
@Override
protected @NonNull WebRtcServiceState handleSendRemoteMuteRequest(@NonNull WebRtcServiceState currentState, @NonNull CallParticipant participant) {
Log.i(tag, "handleSendRemoteMuteRequest():");
GroupCall groupCall = currentState.getCallInfoState().requireGroupCall();
try {
groupCall.sendRemoteMuteRequest(participant.getCallParticipantId().demuxId);
} catch (CallException e) {
Log.w(tag, "Failed to send remote mute request.", e);
}
return currentState;
}
@Override
protected @NonNull WebRtcServiceState handleGroupCallSpeechEvent(@NonNull WebRtcServiceState currentState, @NonNull GroupCall.SpeechEvent speechEvent) {
Log.i(tag, "handleGroupCallSpeechEvent :: " + speechEvent.name());

View File

@@ -393,6 +393,10 @@ public final class SignalCallManager implements CallManager.Observer, GroupCall.
process((s, p) -> p.handleSetCallLinkJoinRequestRejected(s, participant));
}
public void sendRemoteMuteRequest(@NonNull CallParticipant participant) {
process((s, p) -> p.handleSendRemoteMuteRequest(s, participant));
}
public void removeFromCallLink(@NonNull CallParticipant participant) {
process((s, p) -> p.handleRemoveFromCallLink(s, participant));
}

View File

@@ -973,6 +973,12 @@ public abstract class WebRtcActionProcessor {
return currentState;
}
protected @NonNull WebRtcServiceState handleSendRemoteMuteRequest(@NonNull WebRtcServiceState currentState, @NonNull CallParticipant participant) {
Log.i(tag, "handleSendRemoteMuteRequest not processed");
return currentState;
}
protected @NonNull WebRtcServiceState handleRemoveFromCallLink(@NonNull WebRtcServiceState currentState, @NonNull CallParticipant participant) {
Log.i(tag, "handleRemoveFromCallLink not processed");