Add additional call links moderation ui.

This commit is contained in:
Alex Hart
2023-08-08 15:10:47 -03:00
parent 7c209db146
commit 30d0b6fd0e
20 changed files with 531 additions and 105 deletions

View File

@@ -32,8 +32,10 @@ import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
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.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
@@ -45,6 +47,7 @@ import androidx.core.os.bundleOf
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.kotlin.subscribeBy
import kotlinx.collections.immutable.ImmutableList
@@ -66,6 +69,8 @@ import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsViewModel
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.database.CallLinkTable
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.events.CallParticipant
import org.thoughtcrime.securesms.events.WebRtcViewModel
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
@@ -115,8 +120,8 @@ class CallLinkInfoSheet : ComposeBottomSheetDialogFragment() {
override fun SheetContent() {
val callLinkDetailsState by callLinkDetailsViewModel.state
val callParticipantsState by webRtcCallViewModel.callParticipantsState.observeAsState()
val participants = if (callParticipantsState?.callState == WebRtcViewModel.State.CALL_CONNECTED) {
listOf(Recipient.self()) + (callParticipantsState?.allRemoteParticipants?.map { it.recipient } ?: emptyList())
val participants: ImmutableList<CallParticipant> = if (callParticipantsState?.callState == WebRtcViewModel.State.CALL_CONNECTED) {
listOf(CallParticipant(recipient = Recipient.self())) + (callParticipantsState?.allRemoteParticipants?.map { it } ?: emptyList())
} else {
emptyList()
}.toImmutableList()
@@ -137,11 +142,24 @@ class CallLinkInfoSheet : ComposeBottomSheetDialogFragment() {
onShareLinkClicked = this::shareLink,
onEditNameClicked = onEditNameClicked,
onToggleAdminApprovalClicked = this::onApproveAllMembersChanged,
onBlock = {} // TODO [alex] -- Blocking
onBlock = this::onBlockParticipant
)
}
}
private fun onBlockParticipant(callParticipant: CallParticipant) {
MaterialAlertDialogBuilder(requireContext())
.setNegativeButton(android.R.string.cancel, null)
.setMessage(getString(R.string.CallLinkInfoSheet__remove_s_from_the_call, callParticipant.recipient.getShortDisplayName(requireContext())))
.setPositiveButton(R.string.CallLinkInfoSheet__remove) { _, _ ->
ApplicationDependencies.getSignalCallManager().removeFromCallLink(callParticipant)
}
.setNeutralButton(R.string.CallLinkInfoSheet__block_from_call) { _, _ ->
ApplicationDependencies.getSignalCallManager().blockFromCallLink(callParticipant)
}
.show()
}
private fun onApproveAllMembersChanged(checked: Boolean) {
callLinkDetailsViewModel.setApproveAllMembers(checked)
.observeOn(AndroidSchedulers.mainThread())
@@ -210,7 +228,7 @@ private fun SheetPreview() {
),
state = SignalCallLinkState()
),
participants = listOf(Recipient.UNKNOWN).toImmutableList(),
participants = listOf(CallParticipant(recipient = Recipient.UNKNOWN)).toImmutableList(),
onShareLinkClicked = {},
onEditNameClicked = {},
onToggleAdminApprovalClicked = {},
@@ -223,17 +241,24 @@ private fun SheetPreview() {
@Composable
private fun Sheet(
callLink: CallLinkTable.CallLink,
participants: ImmutableList<Recipient>,
participants: ImmutableList<CallParticipant>,
onShareLinkClicked: () -> Unit,
onEditNameClicked: () -> Unit,
onToggleAdminApprovalClicked: (Boolean) -> Unit,
onBlock: (Recipient) -> Unit
onBlock: (CallParticipant) -> Unit
) {
LazyColumn(
horizontalAlignment = Alignment.CenterHorizontally
) {
item {
BottomSheets.Handle()
Text(
text = stringResource(id = R.string.CallLinkInfoSheet__call_info),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(vertical = 24.dp)
)
SignalCallRow(callLink = callLink, onJoinClicked = null)
Rows.TextRow(
text = stringResource(id = R.string.CallLinkDetailsFragment__share_link),
@@ -251,9 +276,9 @@ private fun Sheet(
)
}
items(participants, { it.id }, { null }) {
items(participants, { it.callParticipantId }, { null }) {
CallLinkMemberRow(
recipient = it,
callParticipant = it,
isSelfAdmin = callLink.credentials?.adminPassBytes != null,
onBlockClicked = onBlock
)
@@ -282,7 +307,7 @@ private fun CallLinkMemberRowPreview() {
SignalTheme(isDarkMode = true) {
Surface {
CallLinkMemberRow(
Recipient.UNKNOWN,
CallParticipant(recipient = Recipient.UNKNOWN),
isSelfAdmin = true,
{}
)
@@ -292,37 +317,45 @@ private fun CallLinkMemberRowPreview() {
@Composable
private fun CallLinkMemberRow(
recipient: Recipient,
callParticipant: CallParticipant,
isSelfAdmin: Boolean,
onBlockClicked: (Recipient) -> Unit
onBlockClicked: (CallParticipant) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(Rows.defaultPadding())
) {
AndroidView(
factory = ::AvatarImageView,
modifier = Modifier.size(40.dp)
) {
it.setAvatarUsingProfile(recipient)
if (LocalInspectionMode.current) {
Spacer(
modifier = Modifier
.size(40.dp)
.background(color = Color.Red, shape = CircleShape)
)
} else {
AndroidView(
factory = ::AvatarImageView,
modifier = Modifier.size(40.dp)
) {
it.setAvatarUsingProfile(callParticipant.recipient)
}
}
Spacer(modifier = Modifier.width(24.dp))
Text(
text = recipient.getShortDisplayName(LocalContext.current),
text = callParticipant.recipient.getShortDisplayName(LocalContext.current),
modifier = Modifier
.weight(1f)
.align(Alignment.CenterVertically)
)
if (isSelfAdmin) {
if (isSelfAdmin && !callParticipant.recipient.isSelf) {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_minus_circle_24),
contentDescription = null,
modifier = Modifier
.clickable(onClick = { onBlockClicked(recipient) })
.clickable(onClick = { onBlockClicked(callParticipant) })
.align(Alignment.CenterVertically)
)
}

View File

@@ -36,7 +36,8 @@ data class CallParticipantsState(
val isInOutgoingRingingMode: Boolean = false,
val ringGroup: Boolean = false,
val ringerRecipient: Recipient = Recipient.UNKNOWN,
val groupMembers: List<GroupMemberEntry.FullMember> = emptyList()
val groupMembers: List<GroupMemberEntry.FullMember> = emptyList(),
val isCallLink: Boolean = false
) {
val allRemoteParticipants: List<CallParticipant> = remoteParticipants.allParticipants
@@ -80,6 +81,7 @@ data class CallParticipantsState(
return if (remoteParticipants.isEmpty) {
describeGroupMembers(
context = context,
noParticipants = if (isCallLink) R.string.WebRtcCallView__signal_call_link else null,
oneParticipant = if (ringGroup) R.string.WebRtcCallView__signal_will_ring_s else R.string.WebRtcCallView__s_will_be_notified,
twoParticipants = if (ringGroup) R.string.WebRtcCallView__signal_will_ring_s_and_s else R.string.WebRtcCallView__s_and_s_will_be_notified,
multipleParticipants = if (ringGroup) R.plurals.WebRtcCallView__signal_will_ring_s_s_and_d_others else R.plurals.WebRtcCallView__s_s_and_d_others_will_be_notified,
@@ -115,6 +117,7 @@ data class CallParticipantsState(
) {
return describeGroupMembers(
context = context,
noParticipants = null,
oneParticipant = R.string.WebRtcCallView__ringing_s,
twoParticipants = R.string.WebRtcCallView__ringing_s_and_s,
multipleParticipants = R.plurals.WebRtcCallView__ringing_s_s_and_d_others,
@@ -223,7 +226,8 @@ data class CallParticipantsState(
remoteDevicesCount = webRtcViewModel.remoteDevicesCount,
ringGroup = webRtcViewModel.ringGroup,
isInOutgoingRingingMode = isInOutgoingRingingMode,
ringerRecipient = webRtcViewModel.ringerRecipient
ringerRecipient = webRtcViewModel.ringerRecipient,
isCallLink = webRtcViewModel.isCallLink
)
}
@@ -315,6 +319,7 @@ data class CallParticipantsState(
private fun describeGroupMembers(
context: Context,
@StringRes noParticipants: Int?,
@StringRes oneParticipant: Int,
@StringRes twoParticipants: Int,
@PluralsRes multipleParticipants: Int,
@@ -323,7 +328,7 @@ data class CallParticipantsState(
val eligibleMembers: List<GroupMemberEntry.FullMember> = members.filterNot { it.member.isSelf || it.member.isBlocked }
return when (eligibleMembers.size) {
0 -> ""
0 -> noParticipants?.let { context.getString(noParticipants) } ?: ""
1 -> context.getString(
oneParticipant,
eligibleMembers[0].member.getShortDisplayName(context)

View File

@@ -0,0 +1,22 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.webrtc
/**
* Data interface for the in-call status text to be displayed while a call
* is ongoing.
*/
sealed interface InCallStatus {
/**
* The elapsed time the call has been connected for.
*/
data class ElapsedTime(val elapsedTime: Long) : InCallStatus
/**
* The number of users requesting to join a call.
*/
data class PendingUsers(val pendingUserCount: Int) : InCallStatus
}

View File

@@ -5,6 +5,7 @@
package org.thoughtcrime.securesms.components.webrtc
import android.os.Bundle
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
@@ -60,6 +61,13 @@ class PendingParticipantsBottomSheet : ComposeBottomSheetDialogFragment() {
companion object {
const val REQUEST_KEY = "PendingParticipantsBottomSheet_result"
private const val ACTION = "PendingParticipantsBottomSheet_action"
@JvmStatic
fun getAction(bundle: Bundle): Action {
val code = bundle.getInt(ACTION, 0)
return Action.values().first { it.code == code }
}
}
private val viewModel: WebRtcCallViewModel by activityViewModel {
@@ -88,21 +96,39 @@ class PendingParticipantsBottomSheet : ComposeBottomSheetDialogFragment() {
}
private fun onApprove(recipient: Recipient) {
ApplicationDependencies.getSignalCallManager().setCallLinkJoinRequestAccepted(recipient)
ApplicationDependencies.getSignalCallManager().setCallLinkJoinRequestAccepted(recipient.id)
}
private fun onDeny(recipient: Recipient) {
ApplicationDependencies.getSignalCallManager().setCallLinkJoinRequestRejected(recipient)
ApplicationDependencies.getSignalCallManager().setCallLinkJoinRequestRejected(recipient.id)
}
private fun onApproveAll() {
dismiss()
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to true))
setFragmentResult(
REQUEST_KEY,
bundleOf(
ACTION to Action.APPROVE_ALL.code
)
)
}
private fun onDenyAll() {
dismiss()
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to false))
setFragmentResult(
REQUEST_KEY,
bundleOf(
ACTION to Action.DENY_ALL.code
)
)
}
enum class Action(val code: Int) {
NONE(0),
APPROVE_ALL(1),
DENY_ALL(2)
}
}
@@ -112,8 +138,16 @@ 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)
pendingParticipants = listOf(
PendingParticipantCollection.State.PENDING,
PendingParticipantCollection.State.APPROVED,
PendingParticipantCollection.State.DENIED
).map {
PendingParticipantCollection.Entry(
recipient = Recipient.UNKNOWN,
state = it,
stateChangeAt = System.currentTimeMillis().milliseconds
)
},
onApproveAll = {},
onDenyAll = {},
@@ -232,6 +266,11 @@ private fun PendingParticipantRow(
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),
state = when (participant.state) {
PendingParticipantCollection.State.PENDING -> CircularIconButtonState.NORMAL
PendingParticipantCollection.State.APPROVED -> CircularIconButtonState.INVISIBLE
PendingParticipantCollection.State.DENIED -> CircularIconButtonState.DISABLED
},
onClick = onDenyCallback
)
@@ -241,6 +280,11 @@ private fun PendingParticipantRow(
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),
state = when (participant.state) {
PendingParticipantCollection.State.PENDING -> CircularIconButtonState.NORMAL
PendingParticipantCollection.State.APPROVED -> CircularIconButtonState.DISABLED
PendingParticipantCollection.State.DENIED -> CircularIconButtonState.INVISIBLE
},
onClick = onApproveCallback
)
}
@@ -251,20 +295,27 @@ private fun CircularIconButton(
symbol: ImageVector,
contentDescription: String?,
backgroundColor: Color,
state: CircularIconButtonState,
onClick: () -> Unit
) {
Icon(
imageVector = symbol,
contentDescription = contentDescription,
modifier = Modifier
.size(28.dp)
.background(
color = backgroundColor,
shape = CircleShape
)
.clickable(onClick = onClick)
.padding(6.dp)
)
if (state == CircularIconButtonState.INVISIBLE) {
Spacer(modifier = Modifier.size(28.dp))
} else {
val enabled = state != CircularIconButtonState.DISABLED
Icon(
imageVector = symbol,
contentDescription = contentDescription,
modifier = Modifier
.size(28.dp)
.background(
color = if (enabled) backgroundColor else MaterialTheme.colorScheme.secondaryContainer,
shape = CircleShape
)
.clickable(enabled = enabled, onClick = onClick)
.padding(6.dp)
)
}
}
@Composable
@@ -289,3 +340,9 @@ private fun PendingParticipantAvatar(recipient: Recipient) {
}
}
}
private enum class CircularIconButtonState {
NORMAL,
DISABLED,
INVISIBLE
}

View File

@@ -129,6 +129,7 @@ public class WebRtcCallView extends ConstraintLayout {
private Toolbar collapsedToolbar;
private Toolbar headerToolbar;
private Stub<PendingParticipantsView> pendingParticipantsViewStub;
private Stub<View> callLinkWarningCard;
private WebRtcCallParticipantsPagerAdapter pagerAdapter;
private WebRtcCallParticipantsRecyclerAdapter recyclerAdapter;
@@ -207,6 +208,7 @@ public class WebRtcCallView extends ConstraintLayout {
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));
callLinkWarningCard = new Stub<>(findViewById(R.id.call_screen_call_link_warning));
View decline = findViewById(R.id.call_screen_decline_call);
View answerLabel = findViewById(R.id.call_screen_answer_call_label);
@@ -462,10 +464,13 @@ public class WebRtcCallView extends ConstraintLayout {
if (state.getGroupCallState().isNotIdle()) {
if (state.getCallState() == WebRtcViewModel.State.CALL_PRE_JOIN) {
callLinkWarningCard.setVisibility(callParticipantsViewState.isStartedFromCallLink() ? View.VISIBLE : View.GONE);
setStatus(state.getPreJoinGroupDescription(getContext()));
} else if (state.getCallState() == WebRtcViewModel.State.CALL_CONNECTED && state.isInOutgoingRingingMode()) {
callLinkWarningCard.setVisibility(View.GONE);
setStatus(state.getOutgoingRingingGroupDescription(getContext()));
} else if (state.getGroupCallState().isRinging()) {
callLinkWarningCard.setVisibility(View.GONE);
setStatus(state.getIncomingRingingGroupDescription(getContext()));
}
}

View File

@@ -58,7 +58,7 @@ public class WebRtcCallViewModel extends ViewModel {
private final LiveData<WebRtcControls> controlsWithFoldableState = LiveDataUtil.combineLatest(foldableState, webRtcControls, this::updateControlsFoldableState);
private final LiveData<WebRtcControls> realWebRtcControls = LiveDataUtil.combineLatest(isInPipMode, controlsWithFoldableState, this::getRealWebRtcControls);
private final SingleLiveEvent<Event> events = new SingleLiveEvent<>();
private final MutableLiveData<Long> elapsed = new MutableLiveData<>(-1L);
private final BehaviorSubject<Long> elapsed = BehaviorSubject.createDefault(-1L);
private final MutableLiveData<LiveRecipient> liveRecipient = new MutableLiveData<>(Recipient.UNKNOWN.live());
private final DefaultValueLiveData<CallParticipantsState> participantsState = new DefaultValueLiveData<>(CallParticipantsState.STARTING_STATE);
private final SingleLiveEvent<CallParticipantListUpdate> callParticipantListUpdate = new SingleLiveEvent<>();
@@ -142,8 +142,22 @@ public class WebRtcCallViewModel extends ViewModel {
return events;
}
public LiveData<Long> getCallTime() {
return Transformations.map(elapsed, timeInCall -> callConnectedTime == -1 ? -1 : timeInCall);
public Observable<InCallStatus> getInCallstatus() {
Observable<Long> elapsedTime = elapsed.map(timeInCall -> callConnectedTime == -1 ? -1 : timeInCall);
return Observable.combineLatest(
elapsedTime,
pendingParticipants,
(time, participants) -> {
Set<PendingParticipantCollection.Entry> pending = participants.getUnresolvedPendingParticipants();
if (pending.isEmpty()) {
return new InCallStatus.ElapsedTime(time);
} else {
return new InCallStatus.PendingUsers(pending.size());
}
}
).distinctUntilChanged();
}
public LiveData<CallParticipantsState> getCallParticipantsState() {
@@ -194,6 +208,10 @@ public class WebRtcCallViewModel extends ViewModel {
return pendingParticipants.observeOn(AndroidSchedulers.mainThread());
}
public @NonNull PendingParticipantCollection getPendingParticipantsSnapshot() {
return pendingParticipants.getValue();
}
@MainThread
public void setIsInPipMode(boolean isInPipMode) {
this.isInPipMode.setValue(isInPipMode);
@@ -466,7 +484,7 @@ public class WebRtcCallViewModel extends ViewModel {
long newValue = (System.currentTimeMillis() - callConnectedTime) / 1000;
elapsed.postValue(newValue);
elapsed.onNext(newValue);
elapsedTimeHandler.postDelayed(elapsedTimeRunnable, 1000);
}