mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-19 08:09:12 +01:00
Add support for remote muting call participants.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user