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

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