Add split-pane UI for create group screen.

This commit is contained in:
jeffrey-signal
2025-10-22 14:30:24 -04:00
committed by Greyson Parrelli
parent d763baa270
commit d6446d2954
9 changed files with 607 additions and 68 deletions

View File

@@ -1059,6 +1059,11 @@
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:exported="false"/>
<activity
android:name=".groups.ui.creategroup.CreateGroupActivityV2"
android:exported="false"
android:theme="@style/Signal.DayNight.NoActionBar" />
<activity android:name=".groups.ui.addtogroup.AddToGroupsActivity"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:exported="false"/>

View File

@@ -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();

View File

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

View File

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

View File

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

View File

@@ -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<RecipientPicker.DisplayMode> = setOf(RecipientPicker.DisplayMode.ALL),
selectionLimits: SelectionLimits? = ContactSelectionArguments.Defaults.SELECTION_LIMITS,
isRefreshing: Boolean,
focusAndShowKeyboard: Boolean = LocalConfiguration.current.screenHeightDp.dp > 600.dp,
shouldResetContactsList: Boolean,
pendingRecipientSelections: Set<RecipientId> = 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<RecipientPicker.DisplayMode>,
searchQuery: String,
isRefreshing: Boolean,
pendingRecipientSelections: Set<RecipientId>,
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<ChatType?>,
resultConsumer: Consumer<Boolean?>
) {
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<RecipientId?>, number: String?, chatType: Optional<ChatType?>) = 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<SelectedContact>, 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<DisplayMode>.flag: Int
get() = fold(initial = 0) { acc, displayMode -> acc or displayMode.flag }
}
}
}

View File

@@ -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<FindByMode> = 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<SelectedContact>, 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
)
}
}

View File

@@ -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<CreateGroupUiState> = 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<SelectedContact>, 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<SelectedContact> = emptyList(),
val totalMembersCount: Int = 0,
val isLookingUpRecipient: Boolean = false,
val pendingRecipientSelections: Set<RecipientId> = emptySet(),
val userMessage: UserMessage? = null
) {
sealed interface UserMessage {
sealed interface Info : UserMessage {
data class RecipientNotSignalUser(val phone: PhoneNumber) : Info
data object NetworkError : Info
}
}
}

View File

@@ -5187,11 +5187,20 @@
<!-- CreateGroupActivity -->
<string name="CreateGroupActivity__skip">Skip</string>
<plurals name="CreateGroupActivity__d_members">
<item quantity="one">%1$d member</item>
<item quantity="other">%1$d members</item>
</plurals>
<!-- Create group screen title displayed when there is at least one group member selected. The placeholder is the total number of group members. -->
<plurals name="CreateGroupActivity__s_members">
<item quantity="one">%1$s member</item>
<item quantity="other">%1$s members</item>
</plurals>
<!-- Accessibility label for navigating to the next screen in the create group flow. -->
<string name="CreateGroupActivity__accessibility_next">Next</string>
<!-- ShareActivity -->
<string name="ShareActivity__share">Share</string>
<!-- Label to describe circular spinner button -->