Implement several parts of the call links admin UX.

This commit is contained in:
Alex Hart
2023-08-03 14:41:21 -03:00
parent b30f47bac4
commit d247e2c111
22 changed files with 1165 additions and 3 deletions

View File

@@ -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)
}
}
}

View File

@@ -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()
}
}

View File

@@ -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;

View File

@@ -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) {