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