mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-22 18:00:02 +01:00
Implement several parts of the call links admin UX.
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.webrtc
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
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.remember
|
||||
import androidx.compose.runtime.rxjava3.subscribeAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import org.signal.core.ui.BottomSheets
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView
|
||||
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.service.webrtc.PendingParticipantCollection
|
||||
import org.thoughtcrime.securesms.util.activityViewModel
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* Displays a list of pending participants attempting to join this call.
|
||||
*/
|
||||
class PendingParticipantsBottomSheet : ComposeBottomSheetDialogFragment() {
|
||||
|
||||
companion object {
|
||||
const val REQUEST_KEY = "PendingParticipantsBottomSheet_result"
|
||||
}
|
||||
|
||||
private val viewModel: WebRtcCallViewModel by activityViewModel {
|
||||
error("Should already exist")
|
||||
}
|
||||
|
||||
override val peekHeightPercentage: Float = 1f
|
||||
|
||||
@Composable
|
||||
override fun SheetContent() {
|
||||
val launchTime = remember {
|
||||
System.currentTimeMillis().milliseconds
|
||||
}
|
||||
|
||||
val participants = viewModel.pendingParticipants
|
||||
.map { it.getAllPendingParticipants(launchTime).toList() }
|
||||
.subscribeAsState(initial = emptyList())
|
||||
|
||||
PendingParticipantsSheet(
|
||||
pendingParticipants = participants.value,
|
||||
onApproveAll = this::onApproveAll,
|
||||
onDenyAll = this::onDenyAll,
|
||||
onApprove = this::onApprove,
|
||||
onDeny = this::onDeny
|
||||
)
|
||||
}
|
||||
|
||||
private fun onApprove(recipient: Recipient) {
|
||||
ApplicationDependencies.getSignalCallManager().setCallLinkJoinRequestAccepted(recipient)
|
||||
}
|
||||
|
||||
private fun onDeny(recipient: Recipient) {
|
||||
ApplicationDependencies.getSignalCallManager().setCallLinkJoinRequestRejected(recipient)
|
||||
}
|
||||
|
||||
private fun onApproveAll() {
|
||||
dismiss()
|
||||
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to true))
|
||||
}
|
||||
|
||||
private fun onDenyAll() {
|
||||
dismiss()
|
||||
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to false))
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showSystemUi = true)
|
||||
@Composable
|
||||
private fun PendingParticipantsSheetPreview() {
|
||||
SignalTheme(isDarkMode = true) {
|
||||
Surface(shape = RoundedCornerShape(18.dp, 18.dp)) {
|
||||
PendingParticipantsSheet(
|
||||
pendingParticipants = (1 until 7).map {
|
||||
PendingParticipantCollection.Entry(Recipient.UNKNOWN, PendingParticipantCollection.State.PENDING, System.currentTimeMillis().milliseconds)
|
||||
},
|
||||
onApproveAll = {},
|
||||
onDenyAll = {},
|
||||
onApprove = {},
|
||||
onDeny = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PendingParticipantsSheet(
|
||||
pendingParticipants: List<PendingParticipantCollection.Entry>,
|
||||
onApproveAll: () -> Unit,
|
||||
onDenyAll: () -> Unit,
|
||||
onApprove: (Recipient) -> Unit,
|
||||
onDeny: (Recipient) -> Unit
|
||||
) {
|
||||
Box {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(bottom = 64.dp)
|
||||
) {
|
||||
BottomSheets.Handle()
|
||||
|
||||
Spacer(Modifier.size(14.dp))
|
||||
|
||||
LazyColumn(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(id = R.string.PendingParticipantsBottomSheet__requests_to_join_this_call),
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Text(
|
||||
text = pluralStringResource(
|
||||
id = R.plurals.PendingParticipantsBottomSheet__d_people_waiting,
|
||||
count = pendingParticipants.size,
|
||||
pendingParticipants.size
|
||||
),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(Modifier.size(24.dp))
|
||||
}
|
||||
|
||||
items(pendingParticipants.size) { index ->
|
||||
PendingParticipantRow(
|
||||
participant = pendingParticipants[index],
|
||||
onApprove = onApprove,
|
||||
onDeny = onDeny
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.background(color = MaterialTheme.colorScheme.background)
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
TextButton(
|
||||
onClick = onDenyAll
|
||||
) {
|
||||
Text(
|
||||
text = "Deny all",
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
|
||||
Buttons.LargeTonal(onClick = onApproveAll) {
|
||||
Text(
|
||||
text = "Approve all"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PendingParticipantRow(
|
||||
participant: PendingParticipantCollection.Entry,
|
||||
onApprove: (Recipient) -> Unit,
|
||||
onDeny: (Recipient) -> Unit
|
||||
) {
|
||||
val onApproveCallback = remember(participant.recipient) { { onApprove(participant.recipient) } }
|
||||
val onDenyCallback = remember(participant.recipient) { { onDeny(participant.recipient) } }
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp)
|
||||
) {
|
||||
PendingParticipantAvatar(recipient = participant.recipient)
|
||||
|
||||
Text(
|
||||
text = participant.recipient.getDisplayName(LocalContext.current),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 16.dp)
|
||||
)
|
||||
|
||||
CircularIconButton(
|
||||
symbol = ImageVector.vectorResource(id = R.drawable.symbol_x_compact_bold_16),
|
||||
contentDescription = stringResource(id = R.string.PendingParticipantsBottomSheet__reject),
|
||||
backgroundColor = colorResource(id = R.color.webrtc_hangup_background),
|
||||
onClick = onDenyCallback
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.size(24.dp))
|
||||
|
||||
CircularIconButton(
|
||||
symbol = ImageVector.vectorResource(id = R.drawable.symbol_check_compact_bold_16),
|
||||
contentDescription = stringResource(id = R.string.PendingParticipantsBottomSheet__approve),
|
||||
backgroundColor = colorResource(id = R.color.signal_accent_green),
|
||||
onClick = onApproveCallback
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CircularIconButton(
|
||||
symbol: ImageVector,
|
||||
contentDescription: String?,
|
||||
backgroundColor: Color,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Icon(
|
||||
imageVector = symbol,
|
||||
contentDescription = contentDescription,
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.background(
|
||||
color = backgroundColor,
|
||||
shape = CircleShape
|
||||
)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(6.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PendingParticipantAvatar(recipient: Recipient) {
|
||||
if (LocalInspectionMode.current) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Person,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.background(
|
||||
color = Color.Red,
|
||||
shape = CircleShape
|
||||
)
|
||||
)
|
||||
} else {
|
||||
AndroidView(
|
||||
factory = ::AvatarImageView,
|
||||
modifier = Modifier.size(40.dp)
|
||||
) {
|
||||
it.setAvatar(recipient)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.webrtc
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import androidx.constraintlayout.widget.Group
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.card.MaterialCardView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.service.webrtc.PendingParticipantCollection
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
|
||||
/**
|
||||
* Card which displays pending participants state.
|
||||
*/
|
||||
class PendingParticipantsView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : MaterialCardView(context, attrs) {
|
||||
init {
|
||||
inflate(context, R.layout.pending_participant_view, this)
|
||||
}
|
||||
|
||||
var listener: Listener? = null
|
||||
|
||||
private val avatar: AvatarImageView = findViewById(R.id.pending_participants_avatar)
|
||||
private val name: TextView = findViewById(R.id.pending_participants_name)
|
||||
private val allow: View = findViewById(R.id.pending_participants_allow)
|
||||
private val reject: View = findViewById(R.id.pending_participants_reject)
|
||||
private val requestsGroup: Group = findViewById(R.id.pending_participants_requests_group)
|
||||
private val requestsButton: MaterialButton = findViewById(R.id.pending_participants_requests)
|
||||
|
||||
init {
|
||||
requestsButton.setOnClickListener {
|
||||
listener?.onLaunchPendingRequestsSheet()
|
||||
}
|
||||
}
|
||||
|
||||
fun applyState(pendingParticipantCollection: PendingParticipantCollection) {
|
||||
val unresolvedPendingParticipants: List<Recipient> = pendingParticipantCollection.getUnresolvedPendingParticipants().map { it.recipient }
|
||||
if (unresolvedPendingParticipants.isEmpty()) {
|
||||
visible = false
|
||||
return
|
||||
}
|
||||
|
||||
val firstRecipient: Recipient = unresolvedPendingParticipants.first()
|
||||
avatar.setAvatar(firstRecipient)
|
||||
name.text = firstRecipient.getShortDisplayName(context)
|
||||
|
||||
allow.setOnClickListener { listener?.onAllowPendingRecipient(firstRecipient) }
|
||||
reject.setOnClickListener { listener?.onRejectPendingRecipient(firstRecipient) }
|
||||
|
||||
if (unresolvedPendingParticipants.size > 1) {
|
||||
val requestCount = unresolvedPendingParticipants.size - 1
|
||||
requestsButton.text = resources.getQuantityString(R.plurals.PendingParticipantsView__plus_d_requests, requestCount, requestCount)
|
||||
requestsGroup.visible = true
|
||||
} else {
|
||||
requestsGroup.visible = false
|
||||
}
|
||||
|
||||
visible = true
|
||||
}
|
||||
|
||||
interface Listener {
|
||||
/**
|
||||
* Given recipient should be admitted to the call
|
||||
*/
|
||||
fun onAllowPendingRecipient(pendingRecipient: Recipient)
|
||||
|
||||
/**
|
||||
* Given recipient should be rejected from the call
|
||||
*/
|
||||
fun onRejectPendingRecipient(pendingRecipient: Recipient)
|
||||
|
||||
/**
|
||||
* Display the sheet containing all of the requests for the given call
|
||||
*/
|
||||
fun onLaunchPendingRequestsSheet()
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,7 @@ import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.ringrtc.CameraState;
|
||||
import org.thoughtcrime.securesms.service.webrtc.PendingParticipantCollection;
|
||||
import org.thoughtcrime.securesms.util.BlurTransformation;
|
||||
import org.thoughtcrime.securesms.util.ThrottledDebouncer;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
@@ -127,10 +128,12 @@ public class WebRtcCallView extends ConstraintLayout {
|
||||
private View fullScreenShade;
|
||||
private Toolbar collapsedToolbar;
|
||||
private Toolbar headerToolbar;
|
||||
private Stub<PendingParticipantsView> pendingParticipantsViewStub;
|
||||
|
||||
private WebRtcCallParticipantsPagerAdapter pagerAdapter;
|
||||
private WebRtcCallParticipantsRecyclerAdapter recyclerAdapter;
|
||||
private PictureInPictureExpansionHelper pictureInPictureExpansionHelper;
|
||||
private PendingParticipantsView.Listener pendingParticipantsViewListener;
|
||||
|
||||
private final Set<View> incomingCallViews = new HashSet<>();
|
||||
private final Set<View> topViews = new HashSet<>();
|
||||
@@ -203,6 +206,7 @@ public class WebRtcCallView extends ConstraintLayout {
|
||||
fullScreenShade = findViewById(R.id.call_screen_full_shade);
|
||||
collapsedToolbar = findViewById(R.id.webrtc_call_view_toolbar_text);
|
||||
headerToolbar = findViewById(R.id.webrtc_call_view_toolbar_no_text);
|
||||
pendingParticipantsViewStub = new Stub<>(findViewById(R.id.call_screen_pending_recipients));
|
||||
|
||||
View decline = findViewById(R.id.call_screen_decline_call);
|
||||
View answerLabel = findViewById(R.id.call_screen_answer_call_label);
|
||||
@@ -424,6 +428,22 @@ public class WebRtcCallView extends ConstraintLayout {
|
||||
micToggle.setChecked(isMicEnabled, false);
|
||||
}
|
||||
|
||||
public void setPendingParticipantsViewListener(@Nullable PendingParticipantsView.Listener listener) {
|
||||
pendingParticipantsViewListener = listener;
|
||||
}
|
||||
|
||||
public void updatePendingParticipantsList(@NonNull PendingParticipantCollection pendingParticipantCollection) {
|
||||
if (pendingParticipantCollection.getUnresolvedPendingParticipants().isEmpty()) {
|
||||
if (pendingParticipantsViewStub.resolved()) {
|
||||
pendingParticipantsViewStub.get().setListener(pendingParticipantsViewListener);
|
||||
pendingParticipantsViewStub.get().applyState(pendingParticipantCollection);
|
||||
}
|
||||
} else {
|
||||
pendingParticipantsViewStub.get().setListener(pendingParticipantsViewListener);
|
||||
pendingParticipantsViewStub.get().applyState(pendingParticipantCollection);
|
||||
}
|
||||
}
|
||||
|
||||
public void updateCallParticipants(@NonNull CallParticipantsViewState callParticipantsViewState) {
|
||||
lastState = callParticipantsViewState;
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.recipients.LiveRecipient;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.service.webrtc.PendingParticipantCollection;
|
||||
import org.thoughtcrime.securesms.service.webrtc.state.WebRtcEphemeralState;
|
||||
import org.thoughtcrime.securesms.util.DefaultValueLiveData;
|
||||
import org.thoughtcrime.securesms.util.NetworkUtil;
|
||||
@@ -44,6 +45,10 @@ import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.rxjava3.core.Observable;
|
||||
import io.reactivex.rxjava3.subjects.BehaviorSubject;
|
||||
|
||||
public class WebRtcCallViewModel extends ViewModel {
|
||||
|
||||
private final MutableLiveData<Boolean> microphoneEnabled = new MutableLiveData<>(true);
|
||||
@@ -70,6 +75,8 @@ public class WebRtcCallViewModel extends ViewModel {
|
||||
private final Observer<List<GroupMemberEntry.FullMember>> groupMemberStateUpdater = m -> participantsState.setValue(CallParticipantsState.update(participantsState.getValue(), m));
|
||||
private final MutableLiveData<WebRtcEphemeralState> ephemeralState = new MutableLiveData<>();
|
||||
|
||||
private final BehaviorSubject<PendingParticipantCollection> pendingParticipants = BehaviorSubject.create();
|
||||
|
||||
private final Handler elapsedTimeHandler = new Handler(Looper.getMainLooper());
|
||||
private final Runnable elapsedTimeRunnable = this::handleTick;
|
||||
private final Runnable stopOutgoingRingingMode = this::stopOutgoingRingingMode;
|
||||
@@ -183,6 +190,10 @@ public class WebRtcCallViewModel extends ViewModel {
|
||||
return callStarting;
|
||||
}
|
||||
|
||||
public @NonNull Observable<PendingParticipantCollection> getPendingParticipants() {
|
||||
return pendingParticipants.observeOn(AndroidSchedulers.mainThread());
|
||||
}
|
||||
|
||||
@MainThread
|
||||
public void setIsInPipMode(boolean isInPipMode) {
|
||||
this.isInPipMode.setValue(isInPipMode);
|
||||
@@ -272,6 +283,8 @@ public class WebRtcCallViewModel extends ViewModel {
|
||||
webRtcViewModel.getParticipantLimit(),
|
||||
webRtcViewModel.getRecipient().isCallLink());
|
||||
|
||||
pendingParticipants.onNext(webRtcViewModel.getPendingParticipants());
|
||||
|
||||
if (newState.isInOutgoingRingingMode()) {
|
||||
cancelTimer();
|
||||
if (!wasInOutgoingRingingMode) {
|
||||
|
||||
Reference in New Issue
Block a user