mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-28 05:35:44 +00:00
Add split pane UI for add to groups screen.
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.groups.ui.addtogroup
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import org.signal.core.ui.compose.AllDevicePreviews
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.Dialogs
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.util.getParcelableArrayListExtraCompat
|
||||
import org.signal.core.util.getParcelableExtraCompat
|
||||
import org.thoughtcrime.securesms.PassphraseRequiredActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.compose.SignalTheme
|
||||
import org.thoughtcrime.securesms.contacts.SelectedContact
|
||||
import org.thoughtcrime.securesms.groups.SelectionLimits
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupErrors
|
||||
import org.thoughtcrime.securesms.groups.ui.addtogroup.AddToGroupsUiState.UserMessage
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.recipients.ui.RecipientPicker
|
||||
import org.thoughtcrime.securesms.recipients.ui.RecipientPickerCallbacks
|
||||
import org.thoughtcrime.securesms.recipients.ui.RecipientPickerScaffold
|
||||
import org.thoughtcrime.securesms.recipients.ui.RecipientSelection
|
||||
|
||||
/**
|
||||
* Allows the user to add a recipient to a group.
|
||||
*/
|
||||
class AddToGroupsActivityV2 : PassphraseRequiredActivity() {
|
||||
companion object {
|
||||
private const val EXTRA_RECIPIENT_ID = "recipient_id"
|
||||
private const val EXTRA_SELECTION_LIMITS = "selection_limits"
|
||||
private const val EXTRA_PRESELECTED_GROUPS = "preselected_groups"
|
||||
|
||||
@JvmOverloads
|
||||
@JvmStatic
|
||||
fun createIntent(
|
||||
context: Context,
|
||||
recipientId: RecipientId,
|
||||
existingGroupMemberships: List<RecipientId>,
|
||||
selectionLimits: SelectionLimits? = null
|
||||
): Intent {
|
||||
return Intent(context, AddToGroupsActivityV2::class.java).apply {
|
||||
putExtra(EXTRA_RECIPIENT_ID, recipientId)
|
||||
putExtra(EXTRA_SELECTION_LIMITS, selectionLimits)
|
||||
putParcelableArrayListExtra(EXTRA_PRESELECTED_GROUPS, ArrayList(existingGroupMemberships))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState, ready)
|
||||
|
||||
val navigateBack = onBackPressedDispatcher::onBackPressed
|
||||
|
||||
setContent {
|
||||
SignalTheme {
|
||||
AddToGroupsScreen(
|
||||
viewModel = viewModel {
|
||||
AddToGroupsViewModelV2(
|
||||
recipientId = intent.getParcelableExtraCompat(EXTRA_RECIPIENT_ID, RecipientId::class.java)!!,
|
||||
selectionLimits = intent.getParcelableExtraCompat(EXTRA_SELECTION_LIMITS, SelectionLimits::class.java),
|
||||
existingGroupMemberships = intent.getParcelableArrayListExtraCompat(EXTRA_PRESELECTED_GROUPS, RecipientId::class.java)!!.toSet()
|
||||
)
|
||||
},
|
||||
closeScreen = navigateBack
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddToGroupsScreen(
|
||||
viewModel: AddToGroupsViewModelV2,
|
||||
closeScreen: () -> Unit
|
||||
) {
|
||||
val callbacks = remember {
|
||||
object : UiCallbacks {
|
||||
override fun onSearchQueryChanged(query: String) = viewModel.onSearchQueryChanged(query)
|
||||
override fun onSelectionChanged(newSelections: List<SelectedContact>, totalMembersCount: Int) = viewModel.selectGroups(newSelections)
|
||||
override fun addToSelectedGroups() = viewModel.addToSelectedGroups()
|
||||
override fun onAddConfirmed(groupRecipient: Recipient) = viewModel.addToGroups(listOf(groupRecipient))
|
||||
override fun onUserMessageDismissed(userMessage: UserMessage) = viewModel.clearUserMessage()
|
||||
override fun onBackPressed() = closeScreen()
|
||||
}
|
||||
}
|
||||
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
AddToGroupsScreenUi(
|
||||
uiState = uiState,
|
||||
callbacks = callbacks
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3AdaptiveApi::class)
|
||||
@Composable
|
||||
private fun AddToGroupsScreenUi(
|
||||
uiState: AddToGroupsUiState,
|
||||
callbacks: UiCallbacks
|
||||
) {
|
||||
val title = if (uiState.isMultiSelectEnabled) {
|
||||
stringResource(R.string.AddToGroupActivity_add_to_groups)
|
||||
} else {
|
||||
stringResource(R.string.AddToGroupActivity_add_to_a_group)
|
||||
}
|
||||
|
||||
RecipientPickerScaffold(
|
||||
title = title,
|
||||
forceSplitPane = uiState.forceSplitPane,
|
||||
onNavigateUpClick = callbacks::onBackPressed,
|
||||
topAppBarActions = {},
|
||||
snackbarHostState = remember { SnackbarHostState() },
|
||||
primaryContent = {
|
||||
AddToGroupsRecipientPicker(
|
||||
uiState = uiState,
|
||||
callbacks = callbacks
|
||||
)
|
||||
|
||||
UserMessagesHost(
|
||||
userMessage = uiState.userMessage,
|
||||
onAddConfirmed = { groupRecipient -> callbacks.onAddConfirmed(groupRecipient) },
|
||||
onDismiss = callbacks::onUserMessageDismissed,
|
||||
closeScreen = callbacks::onBackPressed
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddToGroupsRecipientPicker(
|
||||
uiState: AddToGroupsUiState,
|
||||
callbacks: UiCallbacks,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(modifier = modifier) {
|
||||
RecipientPicker(
|
||||
searchQuery = uiState.searchQuery,
|
||||
displayModes = setOf(RecipientPicker.DisplayMode.ACTIVE_GROUPS, RecipientPicker.DisplayMode.GROUPS_AFTER_CONTACTS),
|
||||
selectionLimits = uiState.selectionLimits,
|
||||
preselectedRecipients = uiState.existingGroupMemberships,
|
||||
includeRecents = true,
|
||||
isRefreshing = false,
|
||||
listBottomPadding = 64.dp,
|
||||
clipListToPadding = false,
|
||||
callbacks = RecipientPickerCallbacks(
|
||||
listActions = callbacks
|
||||
),
|
||||
modifier = modifier.fillMaxSize()
|
||||
)
|
||||
|
||||
if (uiState.isMultiSelectEnabled) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)
|
||||
) {
|
||||
Buttons.MediumTonal(
|
||||
enabled = uiState.newSelections.isNotEmpty(),
|
||||
onClick = callbacks::addToSelectedGroups
|
||||
) {
|
||||
Text(text = stringResource(R.string.AddMembersActivity__done))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private interface UiCallbacks : RecipientPickerCallbacks.ListActions {
|
||||
override suspend fun shouldAllowSelection(selection: RecipientSelection): Boolean = true
|
||||
override fun onRecipientSelected(selection: RecipientSelection) = Unit
|
||||
override fun onPendingRecipientSelectionsConsumed() = Unit
|
||||
fun addToSelectedGroups()
|
||||
fun onAddConfirmed(groupRecipient: Recipient)
|
||||
fun onUserMessageDismissed(userMessage: UserMessage)
|
||||
fun onBackPressed()
|
||||
|
||||
object Empty : UiCallbacks {
|
||||
override fun onSearchQueryChanged(query: String) = Unit
|
||||
override fun addToSelectedGroups() = Unit
|
||||
override fun onAddConfirmed(groupRecipient: Recipient) = Unit
|
||||
override fun onUserMessageDismissed(userMessage: UserMessage) = Unit
|
||||
override fun onBackPressed() = Unit
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UserMessagesHost(
|
||||
userMessage: UserMessage?,
|
||||
onAddConfirmed: (Recipient) -> Unit,
|
||||
onDismiss: (UserMessage) -> Unit,
|
||||
closeScreen: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
when (userMessage) {
|
||||
null -> {}
|
||||
|
||||
is UserMessage.ConfirmAddToGroup -> {
|
||||
AddToGroupConfirmationDialog(
|
||||
message = userMessage,
|
||||
onAddConfirmed = onAddConfirmed,
|
||||
onDismiss = onDismiss
|
||||
)
|
||||
}
|
||||
|
||||
is UserMessage.AddedRecipientToGroup -> {
|
||||
val toastMessage = stringResource(
|
||||
R.string.AddToGroupActivity_s_added_to_s,
|
||||
userMessage.recipient.getDisplayName(context),
|
||||
userMessage.targetGroup.getDisplayName(context)
|
||||
)
|
||||
Toast.makeText(context, toastMessage, Toast.LENGTH_SHORT).show()
|
||||
onDismiss(userMessage)
|
||||
closeScreen()
|
||||
}
|
||||
|
||||
is UserMessage.CantAddRecipientToLegacyGroup -> {
|
||||
Toast.makeText(context, stringResource(R.string.AddToGroupActivity_this_person_cant_be_added_to_legacy_groups), Toast.LENGTH_SHORT).show()
|
||||
onDismiss(userMessage)
|
||||
}
|
||||
|
||||
is UserMessage.GroupUpdateError -> {
|
||||
val toastMessage = stringResource(GroupErrors.getUserDisplayMessage(userMessage.failureReason))
|
||||
Toast.makeText(context, toastMessage, Toast.LENGTH_SHORT).show()
|
||||
onDismiss(userMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddToGroupConfirmationDialog(
|
||||
message: UserMessage.ConfirmAddToGroup,
|
||||
onAddConfirmed: (Recipient) -> Unit,
|
||||
onDismiss: (UserMessage) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val bodyText: String = stringResource(
|
||||
R.string.AddToGroupActivity_add_s_to_s,
|
||||
message.recipientToAdd.getDisplayName(context),
|
||||
message.targetGroup.getDisplayName(context)
|
||||
)
|
||||
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = stringResource(R.string.AddToGroupActivity_add_member),
|
||||
body = bodyText,
|
||||
confirm = stringResource(R.string.AddToGroupActivity_add),
|
||||
dismiss = stringResource(android.R.string.cancel),
|
||||
onConfirm = { onAddConfirmed(message.targetGroup) },
|
||||
onDismiss = { onDismiss(message) }
|
||||
)
|
||||
}
|
||||
|
||||
@AllDevicePreviews
|
||||
@Composable
|
||||
private fun AddToSingleGroupScreenPreview() {
|
||||
Previews.Preview {
|
||||
AddToGroupsScreenUi(
|
||||
uiState = AddToGroupsUiState(
|
||||
forceSplitPane = false,
|
||||
selectionLimits = null
|
||||
),
|
||||
callbacks = UiCallbacks.Empty
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@AllDevicePreviews
|
||||
@Composable
|
||||
private fun AddToMultipleGroupsScreenPreview() {
|
||||
Previews.Preview {
|
||||
AddToGroupsScreenUi(
|
||||
uiState = AddToGroupsUiState(
|
||||
forceSplitPane = false,
|
||||
selectionLimits = SelectionLimits.NO_LIMITS
|
||||
),
|
||||
callbacks = UiCallbacks.Empty
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.groups.ui.addtogroup
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.contacts.SelectedContact
|
||||
import org.thoughtcrime.securesms.groups.SelectionLimits
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason
|
||||
import org.thoughtcrime.securesms.groups.ui.addtogroup.AddToGroupsUiState.UserMessage
|
||||
import org.thoughtcrime.securesms.groups.v2.GroupAddMembersResult
|
||||
import org.thoughtcrime.securesms.groups.v2.GroupManagementRepository
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
||||
class AddToGroupsViewModelV2(
|
||||
private val recipientId: RecipientId,
|
||||
private val existingGroupMemberships: Set<RecipientId>,
|
||||
selectionLimits: SelectionLimits?
|
||||
) : ViewModel() {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(AddToGroupsViewModelV2::class)
|
||||
}
|
||||
|
||||
private val internalUiState = MutableStateFlow(
|
||||
AddToGroupsUiState(
|
||||
existingGroupMemberships = existingGroupMemberships,
|
||||
selectionLimits = selectionLimits
|
||||
)
|
||||
)
|
||||
val uiState: StateFlow<AddToGroupsUiState> = internalUiState.asStateFlow()
|
||||
|
||||
private val repository: GroupManagementRepository = GroupManagementRepository()
|
||||
|
||||
fun onSearchQueryChanged(query: String) {
|
||||
internalUiState.update { it.copy(searchQuery = query) }
|
||||
}
|
||||
|
||||
fun selectGroups(newSelections: List<SelectedContact>) {
|
||||
val selectedGroupIds = newSelections.map { it.getOrCreateRecipientId() }.toSet()
|
||||
|
||||
if (internalUiState.value.isMultiSelectEnabled) {
|
||||
updateSelection(selectedGroupIds)
|
||||
} else {
|
||||
confirmAddToGroup(groupRecipientId = selectedGroupIds.single())
|
||||
}
|
||||
}
|
||||
|
||||
private fun confirmAddToGroup(groupRecipientId: RecipientId) {
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
if (!existingGroupMemberships.contains(groupRecipientId)) {
|
||||
internalUiState.update {
|
||||
it.copy(
|
||||
userMessage = UserMessage.ConfirmAddToGroup(
|
||||
recipientToAdd = Recipient.resolved(recipientId),
|
||||
targetGroup = Recipient.resolved(id = groupRecipientId)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateSelection(selectedGroupIds: Set<RecipientId>) {
|
||||
internalUiState.update {
|
||||
it.copy(
|
||||
searchQuery = "",
|
||||
newSelections = selectedGroupIds
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun addToGroups(groupRecipients: List<Recipient>) {
|
||||
if (groupRecipients.size > 1) {
|
||||
throw UnsupportedOperationException("Multi-select is not yet supported.")
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
val recipient = Recipient.resolved(recipientId)
|
||||
val groupRecipient = groupRecipients.single()
|
||||
|
||||
if (groupRecipient.groupId.get().isV1 && !recipient.hasE164) {
|
||||
internalUiState.update {
|
||||
it.copy(userMessage = UserMessage.CantAddRecipientToLegacyGroup)
|
||||
}
|
||||
return@withContext
|
||||
}
|
||||
|
||||
repository.addMembers(groupRecipient, listOf(recipient.id)) { result ->
|
||||
when (result) {
|
||||
is GroupAddMembersResult.Success -> {
|
||||
internalUiState.update {
|
||||
it.copy(userMessage = UserMessage.AddedRecipientToGroup(recipient, groupRecipient))
|
||||
}
|
||||
}
|
||||
|
||||
is GroupAddMembersResult.Failure -> {
|
||||
internalUiState.update {
|
||||
it.copy(userMessage = UserMessage.GroupUpdateError(result.reason))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addToSelectedGroups() {
|
||||
val selectedGroups = internalUiState.value.newSelections
|
||||
throw UnsupportedOperationException("Not yet built to handle multi-select.")
|
||||
}
|
||||
|
||||
fun clearUserMessage() {
|
||||
internalUiState.update { it.copy(userMessage = null) }
|
||||
}
|
||||
}
|
||||
|
||||
data class AddToGroupsUiState(
|
||||
val forceSplitPane: Boolean = SignalStore.internal.forceSplitPane,
|
||||
val searchQuery: String = "",
|
||||
val existingGroupMemberships: Set<RecipientId> = emptySet(),
|
||||
val selectionLimits: SelectionLimits? = null,
|
||||
val newSelections: Set<RecipientId> = emptySet(),
|
||||
val userMessage: UserMessage? = null
|
||||
) {
|
||||
val isMultiSelectEnabled: Boolean
|
||||
get() = selectionLimits != null
|
||||
|
||||
sealed interface UserMessage {
|
||||
data class ConfirmAddToGroup(val recipientToAdd: Recipient, val targetGroup: Recipient) : UserMessage
|
||||
data class AddedRecipientToGroup(val recipient: Recipient, val targetGroup: Recipient) : UserMessage
|
||||
data object CantAddRecipientToLegacyGroup : UserMessage
|
||||
data class GroupUpdateError(val failureReason: GroupChangeFailureReason) : UserMessage
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,7 @@ fun RecipientPicker(
|
||||
searchQuery: String,
|
||||
displayModes: Set<RecipientPicker.DisplayMode> = setOf(RecipientPicker.DisplayMode.ALL),
|
||||
selectionLimits: SelectionLimits? = ContactSelectionArguments.Defaults.SELECTION_LIMITS,
|
||||
includeRecents: Boolean = ContactSelectionArguments.Defaults.INCLUDE_RECENTS,
|
||||
isRefreshing: Boolean,
|
||||
focusAndShowKeyboard: Boolean = LocalConfiguration.current.screenHeightDp.dp > 600.dp,
|
||||
preselectedRecipients: Set<RecipientId> = emptySet(),
|
||||
@@ -109,6 +110,7 @@ fun RecipientPicker(
|
||||
displayModes = displayModes,
|
||||
selectionLimits = selectionLimits,
|
||||
searchQuery = searchQuery,
|
||||
includeRecents = includeRecents,
|
||||
isRefreshing = isRefreshing,
|
||||
preselectedRecipients = preselectedRecipients,
|
||||
pendingRecipientSelections = pendingRecipientSelections,
|
||||
@@ -128,6 +130,7 @@ fun RecipientPicker(
|
||||
private fun RecipientSearchResultsList(
|
||||
displayModes: Set<RecipientPicker.DisplayMode>,
|
||||
searchQuery: String,
|
||||
includeRecents: Boolean,
|
||||
isRefreshing: Boolean,
|
||||
preselectedRecipients: Set<RecipientId>,
|
||||
pendingRecipientSelections: Set<RecipientId>,
|
||||
@@ -145,6 +148,7 @@ private fun RecipientSearchResultsList(
|
||||
enableFindByUsername = callbacks.findByUsername != null,
|
||||
enableFindByPhoneNumber = callbacks.findByPhoneNumber != null,
|
||||
showCallButtons = callbacks.newCall != null,
|
||||
includeRecents = includeRecents,
|
||||
currentSelection = preselectedRecipients,
|
||||
selectionLimits = selectionLimits,
|
||||
recyclerPadBottom = with(LocalDensity.current) { bottomPadding?.toPx()?.toInt() ?: ContactSelectionArguments.Defaults.RECYCLER_PADDING_BOTTOM },
|
||||
|
||||
Reference in New Issue
Block a user