Add split pane UI for add to groups screen.

This commit is contained in:
jeffrey-signal
2025-11-20 14:47:45 -05:00
parent 872c7c5ce2
commit 1b77a523e4
4 changed files with 466 additions and 0 deletions

View File

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

View File

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

View File

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