From 1b77a523e439eddb105f7221a0be52589654601a Mon Sep 17 00:00:00 2001 From: jeffrey-signal Date: Thu, 20 Nov 2025 14:47:45 -0500 Subject: [PATCH] Add split pane UI for add to groups screen. --- app/src/main/AndroidManifest.xml | 5 + .../ui/addtogroup/AddToGroupsActivityV2.kt | 306 ++++++++++++++++++ .../ui/addtogroup/AddToGroupsViewModelV2.kt | 151 +++++++++ .../recipients/ui/RecipientPicker.kt | 4 + 4 files changed, 466 insertions(+) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/ui/addtogroup/AddToGroupsActivityV2.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/ui/addtogroup/AddToGroupsViewModelV2.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 52c8bd9d30..308803275e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1063,6 +1063,11 @@ android:theme="@style/Theme.Signal.DayNight.NoActionBar" android:exported="false"/> + + , + 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, 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 + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addtogroup/AddToGroupsViewModelV2.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addtogroup/AddToGroupsViewModelV2.kt new file mode 100644 index 0000000000..9614b38fbd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addtogroup/AddToGroupsViewModelV2.kt @@ -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, + selectionLimits: SelectionLimits? +) : ViewModel() { + + companion object { + private val TAG = Log.tag(AddToGroupsViewModelV2::class) + } + + private val internalUiState = MutableStateFlow( + AddToGroupsUiState( + existingGroupMemberships = existingGroupMemberships, + selectionLimits = selectionLimits + ) + ) + val uiState: StateFlow = internalUiState.asStateFlow() + + private val repository: GroupManagementRepository = GroupManagementRepository() + + fun onSearchQueryChanged(query: String) { + internalUiState.update { it.copy(searchQuery = query) } + } + + fun selectGroups(newSelections: List) { + 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) { + internalUiState.update { + it.copy( + searchQuery = "", + newSelections = selectedGroupIds + ) + } + } + + fun addToGroups(groupRecipients: List) { + 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 = emptySet(), + val selectionLimits: SelectionLimits? = null, + val newSelections: Set = 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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/RecipientPicker.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/RecipientPicker.kt index b679d78ef4..5a7c6ab0db 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/RecipientPicker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/RecipientPicker.kt @@ -68,6 +68,7 @@ fun RecipientPicker( searchQuery: String, displayModes: Set = 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 = 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, searchQuery: String, + includeRecents: Boolean, isRefreshing: Boolean, preselectedRecipients: Set, pendingRecipientSelections: Set, @@ -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 },