diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/new/NewCallActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/new/NewCallActivity.kt index 75168e20c9..284e47977f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/new/NewCallActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/new/NewCallActivity.kt @@ -116,7 +116,6 @@ private interface UiCallbacks : RecipientPickerCallbacks.NewCall { override suspend fun shouldAllowSelection(selection: RecipientSelection): Boolean = true - override fun onPendingRecipientSelectionsConsumed() = Unit fun onUserMessageDismissed(userMessage: UserMessage) fun onBackPressed() diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsEvent.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsEvent.kt index fdf6604000..113f3d7a67 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsEvent.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsEvent.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.components.settings.conversation import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.groups.SelectionLimits import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId @@ -13,9 +14,7 @@ sealed class ConversationSettingsEvent { class AddMembersToGroup( val groupId: GroupId, - val selectionWarning: Int, - val selectionLimit: Int, - val isAnnouncementGroup: Boolean, + val selectionLimits: SelectionLimits, val groupMembersWithoutSelf: List ) : ConversationSettingsEvent() diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt index 5828dcdafa..99ed2e1942 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt @@ -955,9 +955,8 @@ class ConversationSettingsFragment : DSLSettingsFragment( requireContext(), addMembersToGroup.groupId, ContactSelectionDisplayMode.FLAG_PUSH, - addMembersToGroup.selectionWarning, - addMembersToGroup.selectionLimit, - addMembersToGroup.isAnnouncementGroup, + addMembersToGroup.selectionLimits.recommendedLimit, + addMembersToGroup.selectionLimits.hardLimit, addMembersToGroup.groupMembersWithoutSelf ), REQUEST_CODE_ADD_MEMBERS_TO_GROUP diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsViewModel.kt index 200ff2bf00..5ff9c12ea4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsViewModel.kt @@ -30,6 +30,7 @@ import org.thoughtcrime.securesms.database.model.StoryViewState import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.groups.GroupId import org.thoughtcrime.securesms.groups.LiveGroup +import org.thoughtcrime.securesms.groups.SelectionLimits import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason import org.thoughtcrime.securesms.groups.v2.GroupAddMembersResult import org.thoughtcrime.securesms.keyvalue.SignalStore @@ -435,9 +436,7 @@ sealed class ConversationSettingsViewModel( internalEvents.onNext( ConversationSettingsEvent.AddMembersToGroup( groupId, - capacityResult.getSelectionWarning(), - capacityResult.getSelectionLimit(), - capacityResult.isAnnouncementGroup, + SelectionLimits(capacityResult.getSelectionWarning(), capacityResult.getSelectionLimit()), capacityResult.getMembersWithoutSelf() ) ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationActivity.kt index b9c55e2776..26c67aeb3c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationActivity.kt @@ -275,7 +275,6 @@ private interface UiCallbacks : fun onRemoveConfirmed(recipient: Recipient) fun onBlockConfirmed(recipient: Recipient) fun onUserMessageDismissed(userMessage: UserMessage) - override fun onPendingRecipientSelectionsConsumed() = Unit fun onBackPressed() object Empty : UiCallbacks { @@ -285,7 +284,6 @@ private interface UiCallbacks : override fun onFindByPhoneNumber() = Unit override suspend fun shouldAllowSelection(selection: RecipientSelection): Boolean = true override fun onRecipientSelected(selection: RecipientSelection) = Unit - override fun onPendingRecipientSelectionsConsumed() = Unit override fun onMessage(id: RecipientId) = Unit override fun onVoiceCall(recipient: Recipient) = Unit override fun onVideoCall(recipient: Recipient) = Unit diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersActivity.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersActivity.java index b2217e255b..5e4fe89c77 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersActivity.java @@ -39,7 +39,6 @@ import java.util.function.Consumer; public class AddMembersActivity extends PushContactSelectionActivity implements ContactSelectionListFragment.FindByCallback { public static final String GROUP_ID = "group_id"; - public static final String ANNOUNCEMENT_GROUP = "announcement_group"; private View done; private AddMembersViewModel viewModel; @@ -50,12 +49,9 @@ public class AddMembersActivity extends PushContactSelectionActivity implements int displayModeFlags, int selectionWarning, int selectionLimit, - boolean isAnnouncementGroup, @NonNull List membersWithoutSelf) { Intent intent = new Intent(context, AddMembersActivity.class); - intent.putExtra(GROUP_ID, groupId.toString()); - intent.putExtra(ANNOUNCEMENT_GROUP, isAnnouncementGroup); intent.putExtra(ContactSelectionArguments.DISPLAY_MODE, displayModeFlags); intent.putExtra(ContactSelectionArguments.SELECTION_LIMITS, new SelectionLimits(selectionWarning, selectionLimit)); intent.putParcelableArrayListExtra(ContactSelectionArguments.CURRENT_SELECTION, new ArrayList<>(membersWithoutSelf)); @@ -184,10 +180,6 @@ public class AddMembersActivity extends PushContactSelectionActivity implements return GroupId.parseOrThrow(getIntent().getStringExtra(GROUP_ID)); } - private boolean isAnnouncementGroup() { - return getIntent().getBooleanExtra(ANNOUNCEMENT_GROUP, false); - } - private void displayAlertMessage(@NonNull AddMembersViewModel.AddMemberDialogMessageState state) { Recipient recipient = Util.firstNonNull(state.getRecipient(), Recipient.UNKNOWN); diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersActivityV2.kt new file mode 100644 index 0000000000..0e981dd5a5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersActivityV2.kt @@ -0,0 +1,339 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.groups.ui.addmembers + +import android.app.Activity.RESULT_CANCELED +import android.app.Activity.RESULT_OK +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.ActivityResult +import androidx.activity.result.ActivityResultLauncher +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.pluralStringResource +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.signal.core.util.nullIfBlank +import org.thoughtcrime.securesms.PassphraseRequiredActivity +import org.thoughtcrime.securesms.PushContactSelectionActivity +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsEvent +import org.thoughtcrime.securesms.compose.SignalTheme +import org.thoughtcrime.securesms.contacts.SelectedContact +import org.thoughtcrime.securesms.database.model.GroupRecord +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.groups.SelectionLimits +import org.thoughtcrime.securesms.groups.ui.addmembers.AddMembersUiState.UserMessage +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.recipients.ui.RecipientLookupFailureMessage +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 +import org.thoughtcrime.securesms.recipients.ui.findby.FindByActivity +import org.thoughtcrime.securesms.recipients.ui.findby.FindByMode +import java.text.NumberFormat + +/** + * Allows members to be added to an existing Signal group by selecting from a list of recipients. + */ +class AddMembersActivityV2 : PassphraseRequiredActivity() { + companion object { + private const val EXTRA_GROUP_ID = "group_id" + private const val EXTRA_SELECTION_LIMITS = "selection_limits" + private const val EXTRA_PRESELECTED_RECIPIENTS = "preselected_recipients" + + fun createIntent( + context: Context, + event: ConversationSettingsEvent.AddMembersToGroup + ): Intent { + return Intent(context, AddMembersActivityV2::class.java).apply { + putExtra(EXTRA_GROUP_ID, event.groupId) + putExtra(EXTRA_SELECTION_LIMITS, event.selectionLimits) + putParcelableArrayListExtra(EXTRA_PRESELECTED_RECIPIENTS, ArrayList(event.groupMembersWithoutSelf)) + } + } + } + + override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { + enableEdgeToEdge() + super.onCreate(savedInstanceState, ready) + + setContent { + SignalTheme { + AddMembersScreen( + viewModel = viewModel { + AddMembersViewModelV2( + groupId = intent.getParcelableExtraCompat(EXTRA_GROUP_ID, GroupId::class.java)!!, + existingMembersMinusSelf = intent.getParcelableArrayListExtraCompat(EXTRA_PRESELECTED_RECIPIENTS, RecipientId::class.java)!!.toSet(), + selectionLimits = intent.getParcelableExtraCompat(EXTRA_SELECTION_LIMITS, SelectionLimits::class.java)!! + ) + }, + activityIntent = intent, + closeScreen = { result -> + setResult(result.resultCode, result.data) + onBackPressedDispatcher.onBackPressed() + } + ) + } + } + } +} + +@Composable +private fun AddMembersScreen( + viewModel: AddMembersViewModelV2, + activityIntent: Intent, + closeScreen: (result: ActivityResult) -> Unit +) { + val findByLauncher: ActivityResultLauncher = rememberLauncherForActivityResult( + contract = FindByActivity.Contract(), + onResult = { id -> id?.let(viewModel::selectRecipient) } + ) + + val callbacks = remember { + object : UiCallbacks { + override fun onSearchQueryChanged(query: String) = viewModel.onSearchQueryChanged(query) + override fun onFindByUsername() = findByLauncher.launch(FindByMode.USERNAME) + override fun onFindByPhoneNumber() = findByLauncher.launch(FindByMode.PHONE_NUMBER) + override suspend fun shouldAllowSelection(selection: RecipientSelection): Boolean = viewModel.shouldAllowSelection(selection) + override fun onSelectionChanged(newSelections: List, totalMembersCount: Int) = viewModel.onSelectionChanged(newSelections) + override fun onPendingRecipientSelectionsConsumed() = viewModel.clearPendingRecipientSelections() + override fun onDoneClicked() = viewModel.addSelectedMembers() + override fun onAddConfirmed(recipientIds: Set) { + val resultIntent = activityIntent.apply { + putParcelableArrayListExtra( + PushContactSelectionActivity.KEY_SELECTED_RECIPIENTS, + ArrayList(recipientIds.toList()) + ) + } + closeScreen(ActivityResult(RESULT_OK, resultIntent)) + } + + override fun onUserMessageDismissed(userMessage: UserMessage) = viewModel.clearUserMessage() + override fun onBackPressed() = closeScreen(ActivityResult(RESULT_CANCELED, null)) + } + } + + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + AddMembersScreenUi( + uiState = uiState, + callbacks = callbacks + ) +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3AdaptiveApi::class) +@Composable +private fun AddMembersScreenUi( + uiState: AddMembersUiState, + callbacks: UiCallbacks +) { + val title = if (uiState.totalMembersCount > 0) { + pluralStringResource( + id = R.plurals.CreateGroupActivity__s_members, + count = uiState.totalMembersCount, + NumberFormat.getInstance().format(uiState.totalMembersCount) + ) + } else { + stringResource(R.string.AddMembersActivity__add_members) + } + + RecipientPickerScaffold( + title = title, + forceSplitPane = uiState.forceSplitPane, + onNavigateUpClick = callbacks::onBackPressed, + topAppBarActions = {}, + snackbarHostState = remember { SnackbarHostState() }, + primaryContent = { + AddMembersRecipientPicker( + uiState = uiState, + callbacks = callbacks + ) + + UserMessagesHost( + userMessage = uiState.userMessage, + onAddConfirmed = callbacks::onAddConfirmed, + onDismiss = callbacks::onUserMessageDismissed + ) + + if (uiState.isLookingUpRecipient) { + Dialogs.IndeterminateProgressDialog() + } + } + ) +} + +@Composable +private fun AddMembersRecipientPicker( + uiState: AddMembersUiState, + callbacks: UiCallbacks, + modifier: Modifier = Modifier +) { + Box(modifier = modifier) { + RecipientPicker( + searchQuery = uiState.searchQuery, + displayModes = setOf(RecipientPicker.DisplayMode.PUSH), + selectionLimits = uiState.selectionLimits, + preselectedRecipients = uiState.existingMembersMinusSelf, + pendingRecipientSelections = uiState.pendingRecipientSelections, + isRefreshing = false, + listBottomPadding = 64.dp, + clipListToPadding = false, + callbacks = RecipientPickerCallbacks( + listActions = callbacks, + findByUsername = callbacks, + findByPhoneNumber = callbacks + ), + modifier = modifier.fillMaxSize() + ) + + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp) + ) { + Buttons.MediumTonal( + enabled = uiState.newSelections.isNotEmpty(), + onClick = callbacks::onDoneClicked + ) { + Text(text = stringResource(R.string.AddMembersActivity__done)) + } + } + } +} + +private interface UiCallbacks : + RecipientPickerCallbacks.ListActions, + RecipientPickerCallbacks.FindByUsername, + RecipientPickerCallbacks.FindByPhoneNumber { + + override fun onRecipientSelected(selection: RecipientSelection) = Unit + fun onDoneClicked() + fun onAddConfirmed(recipientIds: Set) + fun onUserMessageDismissed(userMessage: UserMessage) + fun onBackPressed() + + object Empty : UiCallbacks { + override fun onSearchQueryChanged(query: String) = Unit + override fun onFindByUsername() = Unit + override fun onFindByPhoneNumber() = Unit + override suspend fun shouldAllowSelection(selection: RecipientSelection): Boolean = true + override fun onDoneClicked() = Unit + override fun onAddConfirmed(recipientIds: Set) = Unit + override fun onUserMessageDismissed(userMessage: UserMessage) = Unit + override fun onBackPressed() = Unit + } +} + +@Composable +private fun UserMessagesHost( + userMessage: UserMessage?, + onAddConfirmed: (Set) -> Unit, + onDismiss: (UserMessage) -> Unit +) { + when (userMessage) { + null -> {} + + is UserMessage.RecipientLookupFailed -> { + RecipientLookupFailureMessage( + failure = userMessage.failure, + onDismissed = { onDismiss(userMessage) } + ) + } + + is UserMessage.CantAddRecipientToLegacyGroup -> { + Toast.makeText(LocalContext.current, R.string.AddMembersActivity__this_person_cant_be_added_to_legacy_groups, Toast.LENGTH_SHORT).show() + onDismiss(userMessage) + } + + is UserMessage.GroupAddConfirmation -> { + GroupAddConfirmationDialog( + message = userMessage, + onAddConfirmed = onAddConfirmed, + onDismiss = onDismiss + ) + } + } +} + +@Composable +private fun GroupAddConfirmationDialog( + message: UserMessage.GroupAddConfirmation, + onAddConfirmed: (Set) -> Unit, + onDismiss: (UserMessage) -> Unit +) { + val context: Context = LocalContext.current + val bodyText: String = when (message) { + is UserMessage.ConfirmAddMember -> { + stringResource( + id = R.string.AddMembersActivityV2__add_member_to_s, + message.recipient.getDisplayName(context), + message.group.getDisplayTitle(context) + ) + } + + is UserMessage.ConfirmAddMembers -> { + pluralStringResource( + id = R.plurals.AddMembersActivityV2__add_d_members_to_s, + message.recipientIds.size, + message.recipientIds.size, + message.group.getDisplayTitle(context) + ) + } + } + + Dialogs.SimpleAlertDialog( + title = "", + body = bodyText, + confirm = stringResource(R.string.AddMembersActivity__add), + dismiss = stringResource(android.R.string.cancel), + onConfirm = { onAddConfirmed(message.recipientIds) }, + onDismiss = { onDismiss(message) } + ) +} + +private fun GroupRecord.getDisplayTitle(context: Context): String { + return this.title.nullIfBlank() ?: context.getString(R.string.Recipient_unknown) +} + +@AllDevicePreviews +@Composable +private fun AddMembersScreenPreview() { + Previews.Preview { + AddMembersScreenUi( + uiState = AddMembersUiState( + forceSplitPane = false, + selectionLimits = SelectionLimits.NO_LIMITS + ), + callbacks = UiCallbacks.Empty + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersViewModelV2.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersViewModelV2.kt new file mode 100644 index 0000000000..3816e314e8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersViewModelV2.kt @@ -0,0 +1,170 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.groups.ui.addmembers + +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.thoughtcrime.securesms.contacts.SelectedContact +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.GroupRecord +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.groups.SelectionLimits +import org.thoughtcrime.securesms.groups.ui.addmembers.AddMembersUiState.UserMessage +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.recipients.PhoneNumber +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.recipients.RecipientRepository +import org.thoughtcrime.securesms.recipients.ui.RecipientSelection +import kotlin.collections.plus + +class AddMembersViewModelV2( + private val groupId: GroupId, + existingMembersMinusSelf: Set, + selectionLimits: SelectionLimits +) : ViewModel() { + + private val group: GroupRecord = SignalDatabase.groups.requireGroup(groupId) + + private val internalUiState = MutableStateFlow( + AddMembersUiState( + existingMembersMinusSelf = existingMembersMinusSelf, + selectionLimits = selectionLimits + ) + ) + val uiState: StateFlow = internalUiState.asStateFlow() + + fun onSearchQueryChanged(query: String) { + internalUiState.update { it.copy(searchQuery = query) } + } + + suspend fun shouldAllowSelection(selection: RecipientSelection): Boolean { + val recipientHasE164 = selection is RecipientSelection.HasId && + withContext(Dispatchers.IO) { Recipient.resolved(selection.id) }.hasE164 + + return when { + groupId.isV1 && !recipientHasE164 -> { + internalUiState.update { + it.copy(userMessage = UserMessage.CantAddRecipientToLegacyGroup) + } + false + } + + selection is RecipientSelection.HasId -> true + selection is RecipientSelection.HasPhone -> recipientExists(selection.phone) + else -> false + } + } + + private suspend fun recipientExists(phone: PhoneNumber): Boolean { + internalUiState.update { it.copy(isLookingUpRecipient = true) } + + return when (val lookupResult = RecipientRepository.lookup(phone)) { + is RecipientRepository.LookupResult.Success -> { + internalUiState.update { it.copy(isLookingUpRecipient = false) } + true + } + + is RecipientRepository.LookupResult.Failure -> { + internalUiState.update { + it.copy( + isLookingUpRecipient = false, + userMessage = UserMessage.RecipientLookupFailed(failure = lookupResult) + ) + } + false + } + } + } + + fun onSelectionChanged(newSelections: List) { + internalUiState.update { + it.copy( + searchQuery = "", + newSelections = newSelections + ) + } + } + + fun addSelectedMembers() { + viewModelScope.launch { + val confirmAddMessage = if (uiState.value.newSelections.size == 1) { + UserMessage.ConfirmAddMember( + group = group, + recipient = withContext(Dispatchers.IO) { + Recipient.resolved(uiState.value.newSelections.single().orCreateRecipientId) + } + ) + } else { + UserMessage.ConfirmAddMembers( + group = group, + recipientIds = uiState.value.newSelections.map { it.orCreateRecipientId }.toSet() + ) + } + + internalUiState.update { it.copy(userMessage = confirmAddMessage) } + } + } + + fun selectRecipient(id: RecipientId) { + internalUiState.update { + it.copy(pendingRecipientSelections = it.pendingRecipientSelections + id) + } + } + + fun clearPendingRecipientSelections() { + internalUiState.update { + it.copy(pendingRecipientSelections = emptySet()) + } + } + + fun clearUserMessage() { + internalUiState.update { it.copy(userMessage = null) } + } +} + +data class AddMembersUiState( + val forceSplitPane: Boolean = SignalStore.internal.forceSplitPane, + val searchQuery: String = "", + val existingMembersMinusSelf: Set = emptySet(), + val selectionLimits: SelectionLimits, + val newSelections: List = emptyList(), + val isLookingUpRecipient: Boolean = false, + val pendingRecipientSelections: Set = emptySet(), + val userMessage: UserMessage? = null +) { + val totalMembersCount: Int + get() = existingMembersMinusSelf.size + newSelections.size + 1 + + sealed interface UserMessage { + data class RecipientLookupFailed(val failure: RecipientRepository.LookupResult.Failure) : UserMessage + data object CantAddRecipientToLegacyGroup : UserMessage + + sealed interface GroupAddConfirmation : UserMessage { + val group: GroupRecord + val recipientIds: Set + } + + data class ConfirmAddMember( + override val group: GroupRecord, + val recipient: Recipient + ) : GroupAddConfirmation { + override val recipientIds: Set = setOf(recipient.id) + } + + data class ConfirmAddMembers( + override val group: GroupRecord, + override val recipientIds: Set + ) : GroupAddConfirmation + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupViewModel.kt index 8adef654b5..e2c82ae1a3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupViewModel.kt @@ -37,8 +37,8 @@ class CreateGroupViewModel : ViewModel() { } suspend fun shouldAllowSelection(selection: RecipientSelection): Boolean = when (selection) { - is RecipientSelection.WithId, is RecipientSelection.WithIdAndPhone -> true - is RecipientSelection.WithPhone -> recipientExists(selection.phone) + is RecipientSelection.HasId -> true + is RecipientSelection.HasPhone -> recipientExists(selection.phone) } private suspend fun recipientExists(phone: PhoneNumber): Boolean { 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 a1fbb11f6c..b679d78ef4 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 @@ -70,6 +70,7 @@ fun RecipientPicker( selectionLimits: SelectionLimits? = ContactSelectionArguments.Defaults.SELECTION_LIMITS, isRefreshing: Boolean, focusAndShowKeyboard: Boolean = LocalConfiguration.current.screenHeightDp.dp > 600.dp, + preselectedRecipients: Set = emptySet(), pendingRecipientSelections: Set = emptySet(), shouldResetContactsList: Boolean = false, listBottomPadding: Dp? = null, @@ -109,6 +110,7 @@ fun RecipientPicker( selectionLimits = selectionLimits, searchQuery = searchQuery, isRefreshing = isRefreshing, + preselectedRecipients = preselectedRecipients, pendingRecipientSelections = pendingRecipientSelections, shouldResetContactsList = shouldResetContactsList, bottomPadding = listBottomPadding, @@ -127,6 +129,7 @@ private fun RecipientSearchResultsList( displayModes: Set, searchQuery: String, isRefreshing: Boolean, + preselectedRecipients: Set, pendingRecipientSelections: Set, shouldResetContactsList: Boolean, selectionLimits: SelectionLimits? = ContactSelectionArguments.Defaults.SELECTION_LIMITS, @@ -142,6 +145,7 @@ private fun RecipientSearchResultsList( enableFindByUsername = callbacks.findByUsername != null, enableFindByPhoneNumber = callbacks.findByPhoneNumber != null, showCallButtons = callbacks.newCall != null, + currentSelection = preselectedRecipients, selectionLimits = selectionLimits, recyclerPadBottom = with(LocalDensity.current) { bottomPadding?.toPx()?.toInt() ?: ContactSelectionArguments.Defaults.RECYCLER_PADDING_BOTTOM }, recyclerChildClipping = clipListToPadding @@ -396,7 +400,7 @@ class RecipientPickerCallbacks( suspend fun shouldAllowSelection(selection: RecipientSelection): Boolean fun onRecipientSelected(selection: RecipientSelection) fun onSelectionChanged(newSelections: List, totalMembersCount: Int) = Unit - fun onPendingRecipientSelectionsConsumed() + fun onPendingRecipientSelectionsConsumed() = Unit fun onContactsListReset() = Unit object Empty : ListActions { diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/RecipientSelection.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/RecipientSelection.kt index b5659e1ed6..d452410281 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/RecipientSelection.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/RecipientSelection.kt @@ -9,7 +9,15 @@ import org.thoughtcrime.securesms.recipients.PhoneNumber import org.thoughtcrime.securesms.recipients.RecipientId sealed interface RecipientSelection { - data class WithId(val id: RecipientId) : RecipientSelection - data class WithPhone(val phone: PhoneNumber) : RecipientSelection - data class WithIdAndPhone(val id: RecipientId, val phone: PhoneNumber) : RecipientSelection + sealed interface HasId : RecipientSelection { + val id: RecipientId + } + + sealed interface HasPhone : RecipientSelection { + val phone: PhoneNumber + } + + data class WithId(override val id: RecipientId) : HasId + data class WithPhone(override val phone: PhoneNumber) : HasPhone + data class WithIdAndPhone(override val id: RecipientId, override val phone: PhoneNumber) : HasId, HasPhone } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ba73f4d6ba..a9298488f3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1396,11 +1396,22 @@ Done This person can\'t be added to legacy groups. + Add \"%1$s\" to \"%2$s\"? Add %3$d members to \"%2$s\"? + + + Add \"%1$s\" to \"%2$s\"? + + + + Add %1$d member to \"%2$s\"? + Add %1$d members to \"%2$s\"? + + Add Add members