diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f2efdcd0f8..c697cddd73 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1059,6 +1059,11 @@ android:theme="@style/Theme.Signal.DayNight.NoActionBar" android:exported="false"/> + + diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ContactFilterView.java b/app/src/main/java/org/thoughtcrime/securesms/components/ContactFilterView.java index 3225dbfd9e..f8819b26a6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ContactFilterView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ContactFilterView.java @@ -147,6 +147,11 @@ public final class ContactFilterView extends FrameLayout { ViewUtil.focusAndShowKeyboard(searchText); } + public void setText(String text) { + searchText.setText(text); + searchText.setSelection(text.length()); + } + public void clear() { searchText.setText(""); notifyListener(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/selection/ContactSelectionArguments.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/selection/ContactSelectionArguments.kt index ee8d8a58d4..8dde67a700 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/selection/ContactSelectionArguments.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/selection/ContactSelectionArguments.kt @@ -86,21 +86,21 @@ data class ContactSelectionArguments( ) } } -} -private object Defaults { - const val DISPLAY_MODE = ContactSelectionDisplayMode.FLAG_ALL - const val IS_REFRESHABLE = true - const val ENABLE_CREATE_NEW_GROUP = false - const val ENABLE_FIND_BY_USERNAME = false - const val ENABLE_FIND_BY_PHONE_NUMBER = false - const val INCLUDE_RECENTS = false - const val INCLUDE_CHAT_TYPES = false - val SELECTION_LIMITS: SelectionLimits? = null - val CURRENT_SELECTION: Set = emptySet() - const val DISPLAY_CHIPS = true - const val RECYCLER_PADDING_BOTTOM = -1 - const val RECYCLER_CHILD_CLIPPING = true + object Defaults { + const val DISPLAY_MODE = ContactSelectionDisplayMode.FLAG_ALL + const val IS_REFRESHABLE = true + const val ENABLE_CREATE_NEW_GROUP = false + const val ENABLE_FIND_BY_USERNAME = false + const val ENABLE_FIND_BY_PHONE_NUMBER = false + const val INCLUDE_RECENTS = false + const val INCLUDE_CHAT_TYPES = false + val SELECTION_LIMITS: SelectionLimits? = null + val CURRENT_SELECTION: Set = emptySet() + const val DISPLAY_CHIPS = true + const val RECYCLER_PADDING_BOTTOM = -1 + const val RECYCLER_CHILD_CLIPPING = true - fun canSelectSelf(selectionLimits: SelectionLimits?): Boolean = selectionLimits == null + fun canSelectSelf(selectionLimits: SelectionLimits?): Boolean = selectionLimits == null + } } 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 30a6330eb6..4d0fbebd32 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationActivity.kt @@ -117,7 +117,7 @@ private fun NewConversationScreen( contract = FindByActivity.Contract(), onResult = { recipientId -> if (recipientId != null) { - viewModel.onMessage(recipientId) + viewModel.openConversation(recipientId) } } ) @@ -125,14 +125,16 @@ private fun NewConversationScreen( val coroutineScope = rememberCoroutineScope() val callbacks = remember { object : UiCallbacks { + override fun onSearchQueryChanged(query: String) = viewModel.onSearchQueryChanged(query) override fun onCreateNewGroup() = createGroupLauncher.launch(CreateGroupActivity.newIntent(context)) override fun onFindByUsername() = findByLauncher.launch(FindByMode.USERNAME) override fun onFindByPhoneNumber() = findByLauncher.launch(FindByMode.PHONE_NUMBER) - override fun shouldAllowSelection(id: RecipientId): Boolean = true - override fun onRecipientSelected(id: RecipientId?, phone: PhoneNumber?) = viewModel.onRecipientSelected(id, phone) - override fun onMessage(id: RecipientId) = viewModel.onMessage(id) - override fun onVoiceCall(recipient: Recipient) = CommunicationActions.startVoiceCall(context, recipient, viewModel::onUserAlreadyInACall) - override fun onVideoCall(recipient: Recipient) = CommunicationActions.startVideoCall(context, recipient, viewModel::onUserAlreadyInACall) + override suspend fun shouldAllowSelection(id: RecipientId?, phone: PhoneNumber?): Boolean = true + override fun onRecipientSelected(id: RecipientId?, phone: PhoneNumber?) = viewModel.openConversation(id, phone) + override fun onPendingRecipientSelectionsConsumed() = Unit + override fun onMessage(id: RecipientId) = viewModel.openConversation(id) + override fun onVoiceCall(recipient: Recipient) = CommunicationActions.startVoiceCall(context, recipient, viewModel::showUserAlreadyInACall) + override fun onVideoCall(recipient: Recipient) = CommunicationActions.startVideoCall(context, recipient, viewModel::showUserAlreadyInACall) override fun onRemove(recipient: Recipient) = viewModel.showRemoveConfirmation(recipient) override fun onRemoveConfirmed(recipient: Recipient) { @@ -146,8 +148,8 @@ private fun NewConversationScreen( override fun onInviteToSignal() = context.startActivity(AppSettingsActivity.invite(context)) override fun onRefresh() = viewModel.refresh() - override fun onUserMessageDismissed(userMessage: UserMessage) = viewModel.onUserMessageDismissed() - override fun onContactsListReset() = viewModel.onContactsListReset() + override fun onUserMessageDismissed(userMessage: UserMessage) = viewModel.clearUserMessage() + override fun onContactsListReset() = viewModel.clearShouldResetContactsList() override fun onBackPressed() = closeScreen() } } @@ -256,7 +258,7 @@ private fun NewConversationScreenUi( snackbarHostState = snackbarHostState ) - if (uiState.isRefreshingRecipient) { + if (uiState.isLookingUpRecipient) { Dialogs.IndeterminateProgressDialog() } } @@ -320,11 +322,13 @@ private interface UiCallbacks : fun onBackPressed() object Empty : UiCallbacks { + override fun onSearchQueryChanged(query: String) = Unit override fun onCreateNewGroup() = Unit override fun onFindByUsername() = Unit override fun onFindByPhoneNumber() = Unit - override fun shouldAllowSelection(id: RecipientId): Boolean = true + override suspend fun shouldAllowSelection(id: RecipientId?, phone: PhoneNumber?): Boolean = true override fun onRecipientSelected(id: RecipientId?, phone: PhoneNumber?) = Unit + override fun onPendingRecipientSelectionsConsumed() = Unit override fun onMessage(id: RecipientId) = Unit override fun onVoiceCall(recipient: Recipient) = Unit override fun onVideoCall(recipient: Recipient) = Unit @@ -347,6 +351,7 @@ private fun NewConversationRecipientPicker( modifier: Modifier = Modifier ) { RecipientPicker( + searchQuery = uiState.searchQuery, isRefreshing = uiState.isRefreshingContacts, shouldResetContactsList = uiState.shouldResetContactsList, callbacks = RecipientPickerCallbacks( diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationViewModel.kt index 17b9559b2f..a5ed39be71 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationViewModel.kt @@ -37,41 +37,39 @@ class NewConversationViewModel : ViewModel() { private val contactsManagementRepo = ContactsManagementRepository(AppDependencies.application) - fun onMessage(id: RecipientId): Unit = openConversation(recipientId = id) + fun onSearchQueryChanged(query: String) { + internalUiState.update { it.copy(searchQuery = query) } + } - fun onRecipientSelected(id: RecipientId?, phone: PhoneNumber?) { + fun openConversation(recipientId: RecipientId) { + internalUiState.update { it.copy(pendingDestination = recipientId) } + } + + fun openConversation(id: RecipientId?, phone: PhoneNumber?) { when { id != null -> openConversation(recipientId = id) SignalStore.account.isRegistered -> { - Log.d(TAG, "[onRecipientSelected] Missing recipientId: attempting to look up.") - resolveAndOpenConversation(phone) + Log.d(TAG, "[openConversation] Missing recipientId: attempting to look up.") + resolveAndOpenConversation(phone!!) } - else -> Log.w(TAG, "[onRecipientSelected] Cannot look up recipient: account not registered.") + else -> Log.w(TAG, "[openConversation] Cannot look up recipient: account not registered.") } } - private fun openConversation(recipientId: RecipientId) { - internalUiState.update { it.copy(pendingDestination = recipientId) } - } - - private fun resolveAndOpenConversation(phone: PhoneNumber?) { + private fun resolveAndOpenConversation(phone: PhoneNumber) { viewModelScope.launch { - internalUiState.update { it.copy(isRefreshingRecipient = true) } + internalUiState.update { it.copy(isLookingUpRecipient = true) } val lookupResult = withContext(Dispatchers.IO) { - if (phone != null) { - RecipientRepository.lookupNewE164(inputE164 = phone.value) - } else { - RecipientRepository.LookupResult.InvalidEntry - } + RecipientRepository.lookupNewE164(inputE164 = phone.value) } when (lookupResult) { is RecipientRepository.LookupResult.Success -> { val recipient = Recipient.resolved(lookupResult.recipientId) - internalUiState.update { it.copy(isRefreshingRecipient = false) } + internalUiState.update { it.copy(isLookingUpRecipient = false) } if (recipient.isRegistered && recipient.hasServiceId) { openConversation(recipient.id) @@ -83,7 +81,7 @@ class NewConversationViewModel : ViewModel() { is RecipientRepository.LookupResult.NotFound, is RecipientRepository.LookupResult.InvalidEntry -> { internalUiState.update { it.copy( - isRefreshingRecipient = false, + isLookingUpRecipient = false, userMessage = Info.RecipientNotSignalUser(phone) ) } @@ -92,7 +90,7 @@ class NewConversationViewModel : ViewModel() { is RecipientRepository.LookupResult.NetworkError -> { internalUiState.update { it.copy( - isRefreshingRecipient = false, + isLookingUpRecipient = false, userMessage = Info.NetworkError ) } @@ -137,11 +135,11 @@ class NewConversationViewModel : ViewModel() { } } - fun onUserAlreadyInACall() { + fun showUserAlreadyInACall() { internalUiState.update { it.copy(userMessage = Info.UserAlreadyInAnotherCall) } } - fun onContactsListReset() { + fun clearShouldResetContactsList() { internalUiState.update { it.copy(shouldResetContactsList = false) } } @@ -157,14 +155,15 @@ class NewConversationViewModel : ViewModel() { } } - fun onUserMessageDismissed() { + fun clearUserMessage() { internalUiState.update { it.copy(userMessage = null) } } } data class NewConversationUiState( val forceSplitPaneOnCompactLandscape: Boolean = SignalStore.internal.forceSplitPaneOnCompactLandscape, - val isRefreshingRecipient: Boolean = false, + val searchQuery: String = "", + val isLookingUpRecipient: Boolean = false, val isRefreshingContacts: Boolean = false, val shouldResetContactsList: Boolean = false, val pendingDestination: RecipientId? = null, @@ -174,7 +173,7 @@ data class NewConversationUiState( sealed interface Info : UserMessage { data class RecipientRemoved(val recipient: Recipient) : Info data class RecipientBlocked(val recipient: Recipient) : Info - data class RecipientNotSignalUser(val phone: PhoneNumber?) : Info + data class RecipientNotSignalUser(val phone: PhoneNumber) : Info data object UserAlreadyInAnotherCall : Info data object NetworkError : Info } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/RecipientPicker.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/RecipientPicker.kt index 4fb01a4f93..52080f68ad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/RecipientPicker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/RecipientPicker.kt @@ -24,6 +24,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.fragment.compose.rememberFragmentState @@ -35,15 +37,20 @@ import kotlinx.coroutines.withContext import org.signal.core.ui.compose.DayNightPreviews import org.signal.core.ui.compose.Fragments import org.signal.core.util.DimensionUnit +import org.signal.core.util.orNull import org.thoughtcrime.securesms.ContactSelectionListFragment import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.ContactFilterView import org.thoughtcrime.securesms.components.menu.ActionItem import org.thoughtcrime.securesms.components.menu.SignalContextMenu +import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode +import org.thoughtcrime.securesms.contacts.SelectedContact import org.thoughtcrime.securesms.contacts.paged.ChatType import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey import org.thoughtcrime.securesms.contacts.selection.ContactSelectionArguments +import org.thoughtcrime.securesms.conversation.RecipientPicker.DisplayMode.Companion.flag import org.thoughtcrime.securesms.conversation.RecipientPickerCallbacks.ContextMenu +import org.thoughtcrime.securesms.groups.SelectionLimits import org.thoughtcrime.securesms.recipients.PhoneNumber import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId @@ -56,21 +63,24 @@ import java.util.function.Consumer */ @Composable fun RecipientPicker( + searchQuery: String, + displayModes: Set = setOf(RecipientPicker.DisplayMode.ALL), + selectionLimits: SelectionLimits? = ContactSelectionArguments.Defaults.SELECTION_LIMITS, isRefreshing: Boolean, focusAndShowKeyboard: Boolean = LocalConfiguration.current.screenHeightDp.dp > 600.dp, - shouldResetContactsList: Boolean, + pendingRecipientSelections: Set = emptySet(), + shouldResetContactsList: Boolean = false, + listBottomPadding: Dp? = null, + clipListToPadding: Boolean = ContactSelectionArguments.Defaults.RECYCLER_CHILD_CLIPPING, callbacks: RecipientPickerCallbacks, modifier: Modifier = Modifier ) { - var searchQuery by rememberSaveable { mutableStateOf("") } - Column( modifier = modifier ) { RecipientSearchField( - onFilterChanged = { filter -> - searchQuery = filter - }, + searchQuery = searchQuery, + onFilterChanged = { filter -> callbacks.listActions.onSearchQueryChanged(query = filter) }, focusAndShowKeyboard = focusAndShowKeyboard, modifier = Modifier .fillMaxWidth() @@ -78,9 +88,14 @@ fun RecipientPicker( ) RecipientSearchResultsList( + displayModes = displayModes, + selectionLimits = selectionLimits, searchQuery = searchQuery, isRefreshing = isRefreshing, + pendingRecipientSelections = pendingRecipientSelections, shouldResetContactsList = shouldResetContactsList, + bottomPadding = listBottomPadding, + clipListToPadding = clipListToPadding, callbacks = callbacks, modifier = Modifier .fillMaxSize() @@ -96,6 +111,7 @@ fun RecipientPicker( */ @Composable private fun RecipientSearchField( + searchQuery: String, onFilterChanged: (String) -> Unit, @StringRes hintText: Int? = null, focusAndShowKeyboard: Boolean = false, @@ -108,6 +124,10 @@ private fun RecipientSearchField( } } + LaunchedEffect(searchQuery) { + wrappedView.setText(searchQuery) + } + // TODO [jeff] This causes the keyboard to re-open on rotation, which doesn't match the existing behavior of ContactFilterView. To fix this, // RecipientSearchField needs to be converted to compose so we can use FocusRequestor. LaunchedEffect(focusAndShowKeyboard) { @@ -134,17 +154,26 @@ private fun RecipientSearchField( @Composable private fun RecipientSearchResultsList( + displayModes: Set, searchQuery: String, isRefreshing: Boolean, + pendingRecipientSelections: Set, shouldResetContactsList: Boolean, + selectionLimits: SelectionLimits? = ContactSelectionArguments.Defaults.SELECTION_LIMITS, + bottomPadding: Dp? = null, + clipListToPadding: Boolean = ContactSelectionArguments.Defaults.RECYCLER_CHILD_CLIPPING, callbacks: RecipientPickerCallbacks, modifier: Modifier = Modifier ) { val fragmentArgs = ContactSelectionArguments( + displayMode = displayModes.flag, isRefreshable = callbacks.refresh != null, enableCreateNewGroup = callbacks.newConversation != null, enableFindByUsername = callbacks.findByUsername != null, - enableFindByPhoneNumber = callbacks.findByPhoneNumber != null + enableFindByPhoneNumber = callbacks.findByPhoneNumber != null, + selectionLimits = selectionLimits, + recyclerPadBottom = with(LocalDensity.current) { bottomPadding?.toPx()?.toInt() ?: ContactSelectionArguments.Defaults.RECYCLER_PADDING_BOTTOM }, + recyclerChildClipping = clipListToPadding ).toArgumentBundle() val fragmentState = rememberFragmentState() @@ -186,6 +215,22 @@ private fun RecipientSearchResultsList( wasRefreshing = isRefreshing } + LaunchedEffect(pendingRecipientSelections) { + if (pendingRecipientSelections.isNotEmpty()) { + currentFragment?.let { fragment -> + pendingRecipientSelections.forEach { recipientId -> + currentFragment?.addRecipientToSelectionIfAble(recipientId) + } + callbacks.listActions.onPendingRecipientSelectionsConsumed() + + callbacks.listActions.onSelectionChanged( + newSelections = fragment.selectedContacts, + totalMembersCount = fragment.totalMemberCount + ) + } + } + } + LaunchedEffect(shouldResetContactsList) { if (shouldResetContactsList) { currentFragment?.reset() @@ -226,19 +271,26 @@ private fun ContactSelectionListFragment.setUpCallbacks( chatType: Optional, resultConsumer: Consumer ) { - val recipientId = recipientId.get() - val shouldAllowSelection = callbacks.listActions.shouldAllowSelection(recipientId) - if (shouldAllowSelection) { - callbacks.listActions.onRecipientSelected( - id = recipientId, - phone = number?.let(::PhoneNumber) - ) + val recipientId = recipientId.orNull() + val phone = number?.let(::PhoneNumber) + + coroutineScope.launch { + val shouldAllowSelection = callbacks.listActions.shouldAllowSelection(recipientId, phone) + if (shouldAllowSelection) { + callbacks.listActions.onRecipientSelected(recipientId, phone) + } + resultConsumer.accept(shouldAllowSelection) } - resultConsumer.accept(shouldAllowSelection) } override fun onContactDeselected(recipientId: Optional, number: String?, chatType: Optional) = Unit - override fun onSelectionChanged() = Unit + + override fun onSelectionChanged() { + callbacks.listActions.onSelectionChanged( + newSelections = fragment.selectedContacts, + totalMembersCount = fragment.totalMemberCount + ) + } }) fragment.setOnItemLongClickListener { anchorView, contactSearchKey, recyclerView -> @@ -331,6 +383,7 @@ private suspend fun showItemContextMenu( @Composable private fun RecipientPickerPreview() { RecipientPicker( + searchQuery = "", isRefreshing = false, shouldResetContactsList = false, callbacks = RecipientPickerCallbacks( @@ -353,13 +406,18 @@ data class RecipientPickerCallbacks( * * This is called before [onRecipientSelected] to provide a chance to prevent the selection. */ - fun shouldAllowSelection(id: RecipientId): Boolean + fun onSearchQueryChanged(query: String) + suspend fun shouldAllowSelection(id: RecipientId?, phone: PhoneNumber?): Boolean fun onRecipientSelected(id: RecipientId?, phone: PhoneNumber?) - fun onContactsListReset() + fun onSelectionChanged(newSelections: List, totalMembersCount: Int) = Unit + fun onPendingRecipientSelectionsConsumed() + fun onContactsListReset() = Unit object Empty : ListActions { - override fun shouldAllowSelection(id: RecipientId): Boolean = false + override fun onSearchQueryChanged(query: String) = Unit + override suspend fun shouldAllowSelection(id: RecipientId?, phone: PhoneNumber?): Boolean = true override fun onRecipientSelected(id: RecipientId?, phone: PhoneNumber?) = Unit + override fun onPendingRecipientSelectionsConsumed() = Unit override fun onContactsListReset() = Unit } } @@ -389,3 +447,28 @@ data class RecipientPickerCallbacks( fun onFindByPhoneNumber() } } + +object RecipientPicker { + /** + * Enum wrapper for [ContactSelectionDisplayMode]. + */ + enum class DisplayMode(val flag: Int) { + PUSH(flag = ContactSelectionDisplayMode.FLAG_PUSH), + SMS(flag = ContactSelectionDisplayMode.FLAG_SMS), + ACTIVE_GROUPS(flag = ContactSelectionDisplayMode.FLAG_ACTIVE_GROUPS), + INACTIVE_GROUPS(flag = ContactSelectionDisplayMode.FLAG_INACTIVE_GROUPS), + SELF(flag = ContactSelectionDisplayMode.FLAG_SELF), + BLOCK(flag = ContactSelectionDisplayMode.FLAG_BLOCK), + HIDE_GROUPS_V1(flag = ContactSelectionDisplayMode.FLAG_HIDE_GROUPS_V1), + HIDE_NEW(flag = ContactSelectionDisplayMode.FLAG_HIDE_NEW), + HIDE_RECENT_HEADER(flag = ContactSelectionDisplayMode.FLAG_HIDE_RECENT_HEADER), + GROUPS_AFTER_CONTACTS(flag = ContactSelectionDisplayMode.FLAG_GROUPS_AFTER_CONTACTS), + GROUP_MEMBERS(flag = ContactSelectionDisplayMode.FLAG_GROUP_MEMBERS), + ALL(flag = ContactSelectionDisplayMode.FLAG_ALL); + + companion object { + val Set.flag: Int + get() = fold(initial = 0) { acc, displayMode -> acc or displayMode.flag } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupActivityV2.kt new file mode 100644 index 0000000000..02d54e7ae6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupActivityV2.kt @@ -0,0 +1,315 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.groups.ui.creategroup + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.setContent +import androidx.activity.result.ActivityResultLauncher +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHost +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.graphics.vector.ImageVector +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +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.ui.compose.Scaffolds +import org.signal.core.ui.compose.theme.SignalTheme +import org.thoughtcrime.securesms.PassphraseRequiredActivity +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.compose.ScreenTitlePane +import org.thoughtcrime.securesms.contacts.SelectedContact +import org.thoughtcrime.securesms.conversation.RecipientPicker +import org.thoughtcrime.securesms.conversation.RecipientPickerCallbacks +import org.thoughtcrime.securesms.groups.SelectionLimits +import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupUiState.UserMessage +import org.thoughtcrime.securesms.recipients.PhoneNumber +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.recipients.ui.findby.FindByActivity +import org.thoughtcrime.securesms.recipients.ui.findby.FindByMode +import org.thoughtcrime.securesms.window.AppScaffold +import org.thoughtcrime.securesms.window.WindowSizeClass +import org.thoughtcrime.securesms.window.rememberAppScaffoldNavigator +import java.text.NumberFormat + +/** + * Allows creation of a Signal group by selecting from a list of recipients. + */ +class CreateGroupActivityV2 : PassphraseRequiredActivity() { + companion object { + @JvmStatic + fun createIntent(context: Context): Intent { + return Intent(context, CreateGroupActivityV2::class.java) + } + } + + override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { + super.onCreate(savedInstanceState, ready) + + val navigateBack = onBackPressedDispatcher::onBackPressed + + setContent { + SignalTheme { + CreateGroupScreen( + closeScreen = navigateBack + ) + } + } + } +} + +@Composable +private fun CreateGroupScreen( + viewModel: CreateGroupViewModel = viewModel { CreateGroupViewModel() }, + closeScreen: () -> 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(id: RecipientId?, phone: PhoneNumber?): Boolean = viewModel.shouldAllowSelection(id, phone) + override fun onSelectionChanged(newSelections: List, totalMembersCount: Int) = viewModel.onSelectionChanged(newSelections, totalMembersCount) + override fun onPendingRecipientSelectionsConsumed() = viewModel.clearPendingRecipientSelections() + override fun onNextClicked(): Unit = viewModel.continueToGroupDetails() + override fun onUserMessageDismissed(userMessage: UserMessage) = viewModel.clearUserMessage() + override fun onBackPressed() = closeScreen() + } + } + + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + CreateGroupScreenUi( + uiState = uiState, + callbacks = callbacks + ) +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3AdaptiveApi::class) +@Composable +private fun CreateGroupScreenUi( + uiState: CreateGroupUiState, + callbacks: UiCallbacks +) { + val windowSizeClass = WindowSizeClass.rememberWindowSizeClass() + val isSplitPane = windowSizeClass.isSplitPane(forceSplitPaneOnCompactLandscape = uiState.forceSplitPaneOnCompactLandscape) + val snackbarHostState = remember { SnackbarHostState() } + + val titleText = if (uiState.newSelections.isNotEmpty()) { + pluralStringResource( + id = R.plurals.CreateGroupActivity__s_members, + count = uiState.totalMembersCount, + NumberFormat.getInstance().format(uiState.totalMembersCount) + ) + } else { + stringResource(R.string.CreateGroupActivity__select_members) + } + + AppScaffold( + topBarContent = { + Scaffolds.DefaultTopAppBar( + title = if (!isSplitPane) titleText else "", + titleContent = { _, title -> Text(text = title, style = MaterialTheme.typography.titleLarge) }, + navigationIcon = ImageVector.vectorResource(R.drawable.symbol_arrow_start_24), + navigationContentDescription = stringResource(R.string.DefaultTopAppBar__navigate_up_content_description), + onNavigationClick = callbacks::onBackPressed + ) + }, + + secondaryContent = { + if (isSplitPane) { + ScreenTitlePane( + title = titleText, + modifier = Modifier.fillMaxSize() + ) + } else { + CreateGroupRecipientPicker( + uiState = uiState, + callbacks = callbacks + ) + } + }, + + primaryContent = { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize() + ) { + CreateGroupRecipientPicker( + uiState = uiState, + callbacks = callbacks, + modifier = Modifier.widthIn(max = windowSizeClass.detailPaneMaxContentWidth) + ) + } + }, + + snackbarHost = { + SnackbarHost(snackbarHostState) + }, + + navigator = rememberAppScaffoldNavigator( + isSplitPane = isSplitPane + ) + ) + + UserMessagesHost( + userMessage = uiState.userMessage, + onDismiss = callbacks::onUserMessageDismissed + ) + + if (uiState.isLookingUpRecipient) { + Dialogs.IndeterminateProgressDialog() + } +} + +@Composable +private fun CreateGroupRecipientPicker( + uiState: CreateGroupUiState, + callbacks: UiCallbacks, + modifier: Modifier = Modifier +) { + Box(modifier = modifier) { + RecipientPicker( + searchQuery = uiState.searchQuery, + displayModes = setOf(RecipientPicker.DisplayMode.PUSH), + selectionLimits = uiState.selectionLimits, + pendingRecipientSelections = uiState.pendingRecipientSelections, + isRefreshing = false, + listBottomPadding = 64.dp, + clipListToPadding = false, + callbacks = RecipientPickerCallbacks( + listActions = callbacks, + findByUsername = callbacks, + findByPhoneNumber = callbacks + ), + modifier = modifier + .fillMaxSize() + .padding(vertical = 12.dp) + ) + + AnimatedContent( + targetState = uiState.newSelections.isNotEmpty(), + transitionSpec = { + ContentTransform( + targetContentEnter = EnterTransition.None, + initialContentExit = ExitTransition.None + ) using SizeTransform(sizeAnimationSpec = { _, _ -> tween(300) }) + }, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp) + ) { hasSelectedContacts -> + if (hasSelectedContacts) { + FilledTonalIconButton( + onClick = callbacks::onNextClicked, + content = { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_arrow_end_24), + contentDescription = stringResource(R.string.CreateGroupActivity__accessibility_next) + ) + } + ) + } else { + Buttons.MediumTonal( + onClick = callbacks::onNextClicked + ) { + Text(text = stringResource(R.string.CreateGroupActivity__skip)) + } + } + } + } +} + +private interface UiCallbacks : + RecipientPickerCallbacks.ListActions, + RecipientPickerCallbacks.FindByUsername, + RecipientPickerCallbacks.FindByPhoneNumber { + + override fun onRecipientSelected(id: RecipientId?, phone: PhoneNumber?) = Unit + fun onNextClicked() + 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(id: RecipientId?, phone: PhoneNumber?): Boolean = true + override fun onPendingRecipientSelectionsConsumed() = Unit + override fun onNextClicked() = Unit + override fun onUserMessageDismissed(userMessage: UserMessage) = Unit + override fun onBackPressed() = Unit + } +} + +@Composable +private fun UserMessagesHost( + userMessage: UserMessage?, + onDismiss: (UserMessage) -> Unit +) { + when (userMessage) { + null -> {} + + is UserMessage.Info.NetworkError -> Dialogs.SimpleMessageDialog( + message = stringResource(R.string.NetworkFailure__network_error_check_your_connection_and_try_again), + dismiss = stringResource(android.R.string.ok), + onDismiss = { onDismiss(userMessage) } + ) + + is UserMessage.Info.RecipientNotSignalUser -> Dialogs.SimpleMessageDialog( + message = stringResource(R.string.NewConversationActivity__s_is_not_a_signal_user, userMessage.phone.displayText), + dismiss = stringResource(android.R.string.ok), + onDismiss = { onDismiss(userMessage) } + ) + } +} + +@AllDevicePreviews +@Composable +private fun CreateGroupScreenPreview() { + Previews.Preview { + CreateGroupScreenUi( + uiState = CreateGroupUiState( + forceSplitPaneOnCompactLandscape = false, + selectionLimits = SelectionLimits.NO_LIMITS + ), + callbacks = UiCallbacks.Empty + ) + } +} 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 new file mode 100644 index 0000000000..66f1b912e2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupViewModel.kt @@ -0,0 +1,118 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.groups.ui.creategroup + +import androidx.lifecycle.ViewModel +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.withContext +import org.thoughtcrime.securesms.contacts.SelectedContact +import org.thoughtcrime.securesms.groups.SelectionLimits +import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupUiState.UserMessage.Info +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.recipients.PhoneNumber +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.recipients.RecipientRepository +import org.thoughtcrime.securesms.util.RemoteConfig + +class CreateGroupViewModel : ViewModel() { + private val internalUiState = MutableStateFlow(CreateGroupUiState()) + val uiState: StateFlow = internalUiState.asStateFlow() + + fun onSearchQueryChanged(query: String) { + internalUiState.update { it.copy(searchQuery = query) } + } + + suspend fun shouldAllowSelection(id: RecipientId?, phone: PhoneNumber?): Boolean { + return if (id != null) true else recipientExists(phone!!) + } + + private suspend fun recipientExists(phone: PhoneNumber): Boolean { + internalUiState.update { it.copy(isLookingUpRecipient = true) } + + val lookupResult = withContext(Dispatchers.IO) { + RecipientRepository.lookupNewE164(inputE164 = phone.value) + } + + return when (lookupResult) { + is RecipientRepository.LookupResult.Success -> { + internalUiState.update { it.copy(isLookingUpRecipient = false) } + true + } + + is RecipientRepository.LookupResult.NotFound, is RecipientRepository.LookupResult.InvalidEntry -> { + internalUiState.update { + it.copy( + isLookingUpRecipient = false, + userMessage = Info.RecipientNotSignalUser(phone) + ) + } + false + } + + is RecipientRepository.LookupResult.NetworkError -> { + internalUiState.update { + it.copy( + isLookingUpRecipient = false, + userMessage = Info.NetworkError + ) + } + false + } + } + } + + fun onSelectionChanged(newSelections: List, totalMembersCount: Int) { + internalUiState.update { + it.copy( + searchQuery = "", + newSelections = newSelections, + totalMembersCount = totalMembersCount + ) + } + } + + fun selectRecipient(id: RecipientId) { + internalUiState.update { + it.copy(pendingRecipientSelections = it.pendingRecipientSelections + id) + } + } + + fun clearPendingRecipientSelections() { + internalUiState.update { + it.copy(pendingRecipientSelections = emptySet()) + } + } + + fun continueToGroupDetails() { + // TODO [jeff] pass selected recipients to AddGroupDetailsActivity + } + + fun clearUserMessage() { + internalUiState.update { it.copy(userMessage = null) } + } +} + +data class CreateGroupUiState( + val forceSplitPaneOnCompactLandscape: Boolean = SignalStore.internal.forceSplitPaneOnCompactLandscape, + val searchQuery: String = "", + val selectionLimits: SelectionLimits = RemoteConfig.groupLimits.excludingSelf(), + val newSelections: List = emptyList(), + val totalMembersCount: Int = 0, + val isLookingUpRecipient: Boolean = false, + val pendingRecipientSelections: Set = emptySet(), + val userMessage: UserMessage? = null +) { + sealed interface UserMessage { + sealed interface Info : UserMessage { + data class RecipientNotSignalUser(val phone: PhoneNumber) : Info + data object NetworkError : Info + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0cf0ec796f..a7d28fcf3b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5187,11 +5187,20 @@ Skip + %1$d member %1$d members + + + %1$s member + %1$s members + + + Next + Share