diff --git a/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java b/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java index 300fbeb9df..43023e29d1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java @@ -73,6 +73,7 @@ import org.thoughtcrime.securesms.components.webrtc.WebRtcCallViewModel; import org.thoughtcrime.securesms.components.webrtc.WebRtcControls; import org.thoughtcrime.securesms.components.webrtc.WifiToCellularPopupWindow; import org.thoughtcrime.securesms.components.webrtc.participantslist.CallParticipantsListDialog; +import org.thoughtcrime.securesms.components.webrtc.requests.CallLinkIncomingRequestSheet; import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.events.WebRtcViewModel; @@ -1088,6 +1089,11 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan public void onLaunchPendingRequestsSheet() { new PendingParticipantsBottomSheet().show(getSupportFragmentManager(), BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG); } + + @Override + public void onLaunchRecipientSheet(@NonNull Recipient pendingRecipient) { + CallLinkIncomingRequestSheet.show(getSupportFragmentManager(), pendingRecipient.getId()); + } } private class WindowLayoutInfoConsumer implements Consumer { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/PendingParticipantsView.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/PendingParticipantsView.kt index 4a8cfb0fe3..1b30cd7cd6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/PendingParticipantsView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/PendingParticipantsView.kt @@ -53,6 +53,8 @@ class PendingParticipantsView @JvmOverloads constructor( val firstRecipient: Recipient = unresolvedPendingParticipants.first() avatar.setAvatar(firstRecipient) + avatar.setOnClickListener { listener?.onLaunchRecipientSheet(firstRecipient) } + name.text = firstRecipient.getShortDisplayName(context) allow.setOnClickListener { listener?.onAllowPendingRecipient(firstRecipient) } @@ -70,6 +72,11 @@ class PendingParticipantsView @JvmOverloads constructor( } interface Listener { + /** + * Display the sheet containing the request for the top level participant + */ + fun onLaunchRecipientSheet(pendingRecipient: Recipient) + /** * Given recipient should be admitted to the call */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/requests/CallLinkIncomingRequestRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/requests/CallLinkIncomingRequestRepository.kt new file mode 100644 index 0000000000..aa64fbe0ec --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/requests/CallLinkIncomingRequestRepository.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.thoughtcrime.securesms.components.webrtc.requests + +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import org.thoughtcrime.securesms.contacts.paged.GroupsInCommon +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId + +class CallLinkIncomingRequestRepository { + + fun getGroupsInCommon(recipientId: RecipientId): Observable { + return Recipient.observable(recipientId).flatMapSingle { recipient -> + if (recipient.hasGroupsInCommon()) { + Single.fromCallable { + val groupsInCommon = SignalDatabase.groups.getGroupsContainingMember(recipient.id, true) + val total = groupsInCommon.size + val names = groupsInCommon.take(2).map { it.title!! } + GroupsInCommon(total, names) + }.observeOn(Schedulers.io()) + } else { + Single.just(GroupsInCommon(0, listOf())) + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/requests/CallLinkIncomingRequestSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/requests/CallLinkIncomingRequestSheet.kt new file mode 100644 index 0000000000..f686d2e180 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/requests/CallLinkIncomingRequestSheet.kt @@ -0,0 +1,235 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.webrtc.requests + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Row +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.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rxjava3.subscribeAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Alignment.Companion.CenterVertically +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 +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.os.bundleOf +import androidx.fragment.app.FragmentManager +import org.signal.core.ui.BottomSheets +import org.signal.core.ui.Dividers +import org.signal.core.ui.Rows +import org.signal.core.ui.theme.SignalTheme +import org.signal.core.util.getParcelableCompat +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.recipients.RecipientId +import org.thoughtcrime.securesms.util.BottomSheetUtil +import org.thoughtcrime.securesms.util.viewModel + +/** + * Displayed when the user presses the user avatar in the call link join request + * bar. + */ +class CallLinkIncomingRequestSheet : ComposeBottomSheetDialogFragment() { + + companion object { + private const val RECIPIENT_ID = "recipient_id" + + @JvmStatic + fun show(fragmentManager: FragmentManager, recipientId: RecipientId) { + CallLinkIncomingRequestSheet().apply { + arguments = bundleOf( + RECIPIENT_ID to recipientId + ) + }.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) + } + } + + override fun isDarkTheme(): Boolean = true + + private val recipientId: RecipientId by lazy { + requireArguments().getParcelableCompat(RECIPIENT_ID, RecipientId::class.java)!! + } + + private val viewModel by viewModel { + CallLinkIncomingRequestViewModel(recipientId) + } + + @Composable + override fun SheetContent() { + val state = viewModel.observeState(LocalContext.current).subscribeAsState(initial = CallLinkIncomingRequestState()) + if (state.value.recipient == Recipient.UNKNOWN) { + return + } + + CallLinkIncomingRequestSheetContent( + state = state.value, + onApproveEntry = this::onApproveEntry, + onDenyEntry = this::onDenyEntry + ) + } + + private fun onApproveEntry() { + ApplicationDependencies.getSignalCallManager().setCallLinkJoinRequestAccepted(recipientId) + dismissAllowingStateLoss() + } + + private fun onDenyEntry() { + ApplicationDependencies.getSignalCallManager().setCallLinkJoinRequestRejected(recipientId) + dismissAllowingStateLoss() + } +} + +@Preview +@Composable +private fun CallLinkIncomingRequestSheetContentPreview() { + SignalTheme(isDarkMode = true) { + Surface { + CallLinkIncomingRequestSheetContent( + state = CallLinkIncomingRequestState( + name = "Miles Morales", + subtitle = "+1 (555) 555-5555", + groupsInCommon = "Member of Webheads", + isSystemContact = true + ), + onApproveEntry = {}, + onDenyEntry = {} + ) + } + } +} + +@Composable +private fun CallLinkIncomingRequestSheetContent( + state: CallLinkIncomingRequestState, + onApproveEntry: () -> Unit, + onDenyEntry: () -> Unit +) { + LazyColumn( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + item { BottomSheets.Handle() } + item { Avatar(state.recipient) } + item { + Title( + recipientName = state.name, + isSystemContact = state.isSystemContact + ) + } + + if (state.subtitle.isNotEmpty()) { + item { + Text( + text = state.subtitle, + modifier = Modifier.padding(4.dp) + ) + } + } + + if (state.groupsInCommon.isNotEmpty()) { + item { + Text( + text = state.groupsInCommon, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(6.dp) + ) + } + } + + item { + Dividers.Default() + } + + item { + Rows.TextRow( + text = stringResource(id = R.string.CallLinkIncomingRequestSheet__approve_entry), + icon = ImageVector.vectorResource(R.drawable.symbol_check_circle_24), + onClick = onApproveEntry + ) + } + + item { + Rows.TextRow( + text = stringResource(id = R.string.CallLinkIncomingRequestSheet__deny_entry), + icon = ImageVector.vectorResource(R.drawable.symbol_x_circle_24), + onClick = onDenyEntry + ) + } + + item { + Spacer(modifier = Modifier.size(32.dp)) + } + } +} + +@Composable +private fun Avatar( + recipient: Recipient +) { + if (LocalInspectionMode.current) { + Spacer( + modifier = Modifier + .padding(top = 13.dp) + .size(80.dp) + .background(color = Color.Red, shape = CircleShape) + ) + } else { + AndroidView( + factory = ::AvatarImageView, + modifier = Modifier + .size(80.dp) + .padding(top = 13.dp) + ) { + it.setAvatarUsingProfile(recipient) + } + } +} + +@Composable +private fun Title( + recipientName: String, + isSystemContact: Boolean +) { + if (isSystemContact) { + Row(modifier = Modifier.padding(top = 12.dp)) { + Text( + text = recipientName, + style = MaterialTheme.typography.headlineMedium + ) + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.symbol_person_circle_24), + contentDescription = null, + modifier = Modifier + .padding(start = 6.dp) + .align(CenterVertically) + ) + } + } else { + Text( + text = recipientName, + style = MaterialTheme.typography.headlineMedium + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/requests/CallLinkIncomingRequestState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/requests/CallLinkIncomingRequestState.kt new file mode 100644 index 0000000000..0a7d71faee --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/requests/CallLinkIncomingRequestState.kt @@ -0,0 +1,17 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.webrtc.requests + +import androidx.compose.runtime.Stable +import org.thoughtcrime.securesms.recipients.Recipient + +data class CallLinkIncomingRequestState( + val recipient: Recipient = Recipient.UNKNOWN, + val name: String = "", + val isSystemContact: Boolean = false, + val subtitle: String = "", + @Stable val groupsInCommon: String = "" +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/requests/CallLinkIncomingRequestViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/requests/CallLinkIncomingRequestViewModel.kt new file mode 100644 index 0000000000..7b5a918845 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/requests/CallLinkIncomingRequestViewModel.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.webrtc.requests + +import android.content.Context +import androidx.lifecycle.ViewModel +import io.reactivex.rxjava3.core.BackpressureStrategy +import io.reactivex.rxjava3.core.Flowable +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.util.rx.RxStore + +class CallLinkIncomingRequestViewModel( + private val recipientId: RecipientId +) : ViewModel() { + + private val repository = CallLinkIncomingRequestRepository() + private val store = RxStore(CallLinkIncomingRequestState()) + private val disposables = CompositeDisposable().apply { + add(store) + } + + override fun onCleared() { + disposables.dispose() + } + + fun observeState(context: Context): Flowable { + disposables += store.update(Recipient.observable(recipientId).toFlowable(BackpressureStrategy.LATEST)) { r, s -> + s.copy( + recipient = r, + name = r.getShortDisplayName(context), + subtitle = r.e164.orElse(""), + isSystemContact = r.isSystemContact + ) + } + + disposables += store.update(repository.getGroupsInCommon(recipientId).toFlowable(BackpressureStrategy.LATEST)) { g, s -> + s.copy(groupsInCommon = g.toDisplayText(context)) + } + + return store.stateFlowable + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a3baf6aa0f..600d3f17fe 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -6099,6 +6099,12 @@ Copied to clipboard + + + Approve entry + + Deny entry + Edit call name