From 5d60ab35dee2d5f0404cbcd735d8e6a71f530992 Mon Sep 17 00:00:00 2001 From: jeffrey-signal Date: Wed, 15 Oct 2025 09:32:47 -0400 Subject: [PATCH] New conversation v2 - Add support for find by username/phone/contacts and group creation. --- .../ContactSelectionListFragment.java | 8 +- .../selection/ContactSelectionArguments.kt | 5 + .../conversation/NewConversationActivityV2.kt | 171 ++++++++++++++++-- .../conversation/NewConversationViewModel.kt | 95 +++++++++- .../securesms/conversation/RecipientPicker.kt | 86 ++++++++- .../securesms/recipients/PhoneNumber.kt | 14 ++ 6 files changed, 349 insertions(+), 30 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/recipients/PhoneNumber.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java index 3783c1f9c4..ac3cf57eb4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java @@ -146,7 +146,7 @@ public final class ContactSelectionListFragment extends LoggingFragment { super.onAttach(context); if (context instanceof NewConversationCallback) { - newConversationCallback = (NewConversationCallback) context; + setNewConversationCallback((NewConversationCallback) context); } if (context instanceof FindByCallback) { @@ -198,6 +198,10 @@ public final class ContactSelectionListFragment extends LoggingFragment { } } + public void setNewConversationCallback(@Nullable NewConversationCallback callback) { + this.newConversationCallback = callback; + } + public void setFindByCallback(@Nullable FindByCallback callback) { this.findByCallback = callback; } @@ -903,7 +907,7 @@ public final class ContactSelectionListFragment extends LoggingFragment { builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_CONTACTS_BANNER.getCode()); } - if (newConversationCallback != null && !hasQuery) { + if (fragmentArgs.getEnableCreateNewGroup() && !hasQuery) { builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.NEW_GROUP.getCode()); } 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 c2e9011f22..ee8d8a58d4 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 @@ -14,6 +14,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId data class ContactSelectionArguments( val displayMode: Int = Defaults.DISPLAY_MODE, val isRefreshable: Boolean = Defaults.IS_REFRESHABLE, + val enableCreateNewGroup: Boolean = Defaults.ENABLE_CREATE_NEW_GROUP, val enableFindByUsername: Boolean = Defaults.ENABLE_FIND_BY_USERNAME, val enableFindByPhoneNumber: Boolean = Defaults.ENABLE_FIND_BY_PHONE_NUMBER, val includeRecents: Boolean = Defaults.INCLUDE_RECENTS, @@ -30,6 +31,7 @@ data class ContactSelectionArguments( return Bundle().apply { putInt(DISPLAY_MODE, displayMode) putBoolean(REFRESHABLE, isRefreshable) + putBoolean(ENABLE_CREATE_NEW_GROUP, enableCreateNewGroup) putBoolean(ENABLE_FIND_BY_USERNAME, enableFindByUsername) putBoolean(ENABLE_FIND_BY_PHONE_NUMBER, enableFindByPhoneNumber) putBoolean(RECENTS, includeRecents) @@ -46,6 +48,7 @@ data class ContactSelectionArguments( companion object { const val DISPLAY_MODE = "display_mode" const val REFRESHABLE = "refreshable" + const val ENABLE_CREATE_NEW_GROUP = "enable_create_new_group" const val ENABLE_FIND_BY_USERNAME = "enable_find_by_username" const val ENABLE_FIND_BY_PHONE_NUMBER = "enable_find_by_phone" const val RECENTS = "recents" @@ -69,6 +72,7 @@ data class ContactSelectionArguments( return ContactSelectionArguments( displayMode = bundle.getInt(DISPLAY_MODE, intent.getIntExtra(DISPLAY_MODE, Defaults.DISPLAY_MODE)), isRefreshable = bundle.getBoolean(REFRESHABLE, intent.getBooleanExtra(REFRESHABLE, Defaults.IS_REFRESHABLE)), + enableCreateNewGroup = bundle.getBoolean(ENABLE_CREATE_NEW_GROUP, intent.getBooleanExtra(ENABLE_CREATE_NEW_GROUP, Defaults.ENABLE_CREATE_NEW_GROUP)), enableFindByUsername = bundle.getBoolean(ENABLE_FIND_BY_USERNAME, intent.getBooleanExtra(ENABLE_FIND_BY_USERNAME, Defaults.ENABLE_FIND_BY_USERNAME)), enableFindByPhoneNumber = bundle.getBoolean(ENABLE_FIND_BY_PHONE_NUMBER, intent.getBooleanExtra(ENABLE_FIND_BY_PHONE_NUMBER, Defaults.ENABLE_FIND_BY_PHONE_NUMBER)), includeRecents = bundle.getBoolean(RECENTS, intent.getBooleanExtra(RECENTS, Defaults.INCLUDE_RECENTS)), @@ -87,6 +91,7 @@ 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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationActivityV2.kt index 353f0d8433..83adf113c7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationActivityV2.kt @@ -5,10 +5,14 @@ package org.thoughtcrime.securesms.conversation +import android.app.Activity.RESULT_OK 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.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -18,22 +22,34 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect 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.platform.LocalContext 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 kotlinx.coroutines.rx3.await import org.signal.core.ui.compose.AllDevicePreviews +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.util.viewModel +import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity +import org.thoughtcrime.securesms.conversation.NewConversationUiState.UserMessage +import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity +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.AppScaffoldWithTopBar import org.thoughtcrime.securesms.window.WindowSizeClass import org.thoughtcrime.securesms.window.rememberAppScaffoldNavigator @@ -49,29 +65,103 @@ class NewConversationActivityV2 : PassphraseRequiredActivity() { fun createIntent(context: Context): Intent = Intent(context, NewConversationActivityV2::class.java) } - private val viewModel by viewModel { NewConversationViewModel() } - override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { super.onCreate(savedInstanceState, ready) - setContent { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val navigateBack = onBackPressedDispatcher::onBackPressed + setContent { SignalTheme { NewConversationScreen( - uiState = uiState, - callbacks = object : Callbacks { - override fun onBackPressed() = onBackPressedDispatcher.onBackPressed() - } + activityIntent = intent, + closeScreen = navigateBack ) } } } } -@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3AdaptiveApi::class) @Composable private fun NewConversationScreen( + viewModel: NewConversationViewModel = viewModel { NewConversationViewModel() }, + activityIntent: Intent, + closeScreen: () -> Unit +) { + val context = LocalContext.current + + val createGroupLauncher: ActivityResultLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), + onResult = { result -> + if (result.resultCode == RESULT_OK) { + closeScreen() + } + } + ) + + val findByLauncher: ActivityResultLauncher = rememberLauncherForActivityResult( + contract = FindByActivity.Contract(), + onResult = { recipientId -> + if (recipientId != null) { + viewModel.onMessage(recipientId) + } + } + ) + + val callbacks = remember { + object : Callbacks { + 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 onInviteToSignal() = context.startActivity(AppSettingsActivity.invite(context)) + override fun onUserMessageDismissed(userMessage: UserMessage) = viewModel.onUserMessageDismissed() + override fun onBackPressed() = closeScreen() + } + } + + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(uiState.pendingDestination) { + uiState.pendingDestination?.let { recipientId -> + openConversation(context, recipientId, activityIntent, onComplete = closeScreen) + } + } + + NewConversationScreenUi( + uiState = uiState, + callbacks = callbacks + ) +} + +private suspend fun openConversation( + context: Context, + recipientId: RecipientId, + activityIntent: Intent? = null, + onComplete: () -> Unit = {} +) { + val intent: Intent = ConversationIntents.createBuilder(context, recipientId, -1L) + .map { builder -> + if (activityIntent != null) { + builder + .withDraftText(activityIntent.getStringExtra(Intent.EXTRA_TEXT)) + .withDataUri(activityIntent.data) + .withDataType(activityIntent.type) + .build() + } else { + builder.build() + } + } + .await() + + context.startActivity(intent) + onComplete() +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3AdaptiveApi::class) +@Composable +private fun NewConversationScreenUi( uiState: NewConversationUiState, callbacks: Callbacks ) { @@ -88,6 +178,7 @@ private fun NewConversationScreen( onNavigationClick = callbacks::onBackPressed ) }, + secondaryContent = { if (isSplitPane) { ScreenTitlePane( @@ -95,7 +186,9 @@ private fun NewConversationScreen( modifier = Modifier.fillMaxSize() ) } else { - RecipientPicker() + NewConversationRecipientPicker( + callbacks = callbacks + ) } }, @@ -104,9 +197,9 @@ private fun NewConversationScreen( contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize() ) { - RecipientPicker( - modifier = Modifier - .widthIn(max = windowSizeClass.detailPaneMaxContentWidth) + NewConversationRecipientPicker( + callbacks = callbacks, + modifier = Modifier.widthIn(max = windowSizeClass.detailPaneMaxContentWidth) ) } }, @@ -115,35 +208,77 @@ private fun NewConversationScreen( isSplitPane = isSplitPane ) ) + + UserMessagesHost( + userMessage = uiState.userMessage, + onDismiss = callbacks::onUserMessageDismissed + ) + + if (uiState.isRefreshingRecipient) { + Dialogs.IndeterminateProgressDialog() + } } -private interface Callbacks { +private interface Callbacks : RecipientPickerCallbacks { + fun onUserMessageDismissed(userMessage: UserMessage) fun onBackPressed() object Empty : Callbacks { + override fun onCreateNewGroup() = Unit + override fun onFindByUsername() = Unit + override fun onFindByPhoneNumber() = Unit + override fun shouldAllowSelection(id: RecipientId): Boolean = true + override fun onRecipientSelected(id: RecipientId?, phone: PhoneNumber?) = Unit + override fun onMessage(id: RecipientId) = Unit + override fun onInviteToSignal() = Unit + override fun onUserMessageDismissed(userMessage: UserMessage) = Unit override fun onBackPressed() = Unit } } @Composable -private fun RecipientPicker( +private fun NewConversationRecipientPicker( + callbacks: Callbacks, modifier: Modifier = Modifier ) { RecipientPicker( + enableCreateNewGroup = true, enableFindByUsername = true, enableFindByPhoneNumber = true, - callbacks = RecipientPickerCallbacks.Empty, // TODO(jeffrey) implement callbacks + callbacks = callbacks, modifier = modifier .fillMaxSize() .padding(vertical = 12.dp) ) } +@Composable +private fun UserMessagesHost( + userMessage: UserMessage?, + onDismiss: (UserMessage) -> Unit +) { + when (userMessage) { + null -> {} + + UserMessage.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.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 NewConversationScreenPreview() { Previews.Preview { - NewConversationScreen( + NewConversationScreenUi( uiState = NewConversationUiState( forceSplitPaneOnCompactLandscape = false ), 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 1720d95707..1fc8665951 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationViewModel.kt @@ -6,16 +6,107 @@ package org.thoughtcrime.securesms.conversation import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.conversation.NewConversationUiState.UserMessage import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.recipients.PhoneNumber +import org.thoughtcrime.securesms.recipients.Recipient.Companion.resolved +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.recipients.RecipientRepository class NewConversationViewModel : ViewModel() { + companion object { + private val TAG = Log.tag(NewConversationViewModel::class) + } + private val _uiState = MutableStateFlow(NewConversationUiState()) val uiState: StateFlow = _uiState.asStateFlow() + + fun onMessage(id: RecipientId): Unit = openConversation(recipientId = id) + + fun onRecipientSelected(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) + } + + else -> Log.w(TAG, "[onRecipientSelected] Cannot look up recipient: account not registered.") + } + } + + private fun openConversation(recipientId: RecipientId) { + _uiState.update { it.copy(pendingDestination = recipientId) } + } + + private fun resolveAndOpenConversation(phone: PhoneNumber?) { + viewModelScope.launch { + _uiState.update { it.copy(isRefreshingRecipient = true) } + + val lookupResult = withContext(Dispatchers.IO) { + if (phone != null) { + RecipientRepository.lookupNewE164(inputE164 = phone.value) + } else { + RecipientRepository.LookupResult.InvalidEntry + } + } + + when (lookupResult) { + is RecipientRepository.LookupResult.Success -> { + val recipient = resolved(lookupResult.recipientId) + _uiState.update { it.copy(isRefreshingRecipient = false) } + + if (recipient.isRegistered && recipient.hasServiceId) { + openConversation(recipient.id) + } else { + Log.d(TAG, "[resolveAndOpenConversation] Lookup successful, but recipient is not registered or has no service ID.") + } + } + + is RecipientRepository.LookupResult.NotFound, is RecipientRepository.LookupResult.InvalidEntry -> { + _uiState.update { + it.copy( + isRefreshingRecipient = false, + userMessage = UserMessage.RecipientNotSignalUser(phone) + ) + } + } + + is RecipientRepository.LookupResult.NetworkError -> { + _uiState.update { + it.copy( + isRefreshingRecipient = false, + userMessage = UserMessage.NetworkError + ) + } + } + } + } + } + + fun onUserMessageDismissed() { + _uiState.update { it.copy(userMessage = null) } + } } data class NewConversationUiState( - val forceSplitPaneOnCompactLandscape: Boolean = SignalStore.internal.forceSplitPaneOnCompactLandscape -) + val forceSplitPaneOnCompactLandscape: Boolean = SignalStore.internal.forceSplitPaneOnCompactLandscape, + val isRefreshingRecipient: Boolean = false, + val pendingDestination: RecipientId? = null, + val userMessage: UserMessage? = null +) { + sealed interface UserMessage { + data class RecipientNotSignalUser(val phone: PhoneNumber?) : UserMessage + data object NetworkError : UserMessage + } +} 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 552e542ecb..ccf16f98c4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/RecipientPicker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/RecipientPicker.kt @@ -27,14 +27,19 @@ import org.signal.core.ui.compose.DayNightPreviews import org.signal.core.ui.compose.Fragments import org.thoughtcrime.securesms.ContactSelectionListFragment import org.thoughtcrime.securesms.components.ContactFilterView +import org.thoughtcrime.securesms.contacts.paged.ChatType import org.thoughtcrime.securesms.contacts.selection.ContactSelectionArguments +import org.thoughtcrime.securesms.recipients.PhoneNumber import org.thoughtcrime.securesms.recipients.RecipientId +import java.util.Optional +import java.util.function.Consumer /** * Provides a recipient search and selection UI. */ @Composable fun RecipientPicker( + enableCreateNewGroup: Boolean, enableFindByUsername: Boolean, enableFindByPhoneNumber: Boolean, callbacks: RecipientPickerCallbacks, @@ -56,6 +61,7 @@ fun RecipientPicker( RecipientSearchResultsList( searchQuery = searchQuery, + enableCreateNewGroup = enableCreateNewGroup, enableFindByUsername = enableFindByUsername, enableFindByPhoneNumber = enableFindByPhoneNumber, callbacks = callbacks, @@ -100,12 +106,14 @@ private fun RecipientSearchField( @Composable private fun RecipientSearchResultsList( searchQuery: String, + enableCreateNewGroup: Boolean, enableFindByUsername: Boolean, enableFindByPhoneNumber: Boolean, callbacks: RecipientPickerCallbacks, modifier: Modifier = Modifier ) { val fragmentArgs = ContactSelectionArguments( + enableCreateNewGroup = enableCreateNewGroup, enableFindByUsername = enableFindByUsername, enableFindByPhoneNumber = enableFindByPhoneNumber ).toArgumentBundle() @@ -118,12 +126,13 @@ private fun RecipientSearchResultsList( fragmentState = fragmentState, onUpdate = { fragment -> currentFragment = fragment - currentFragment?.view?.setPadding(0, 0, 0, 0) - - fragment.setFindByCallback(object : ContactSelectionListFragment.FindByCallback { - override fun onFindByUsername() = callbacks.onFindByUsername() - override fun onFindByPhoneNumber() = callbacks.onFindByPhoneNumber() - }) + fragment.view?.setPadding(0, 0, 0, 0) + fragment.setUpCallbacks( + callbacks = callbacks, + enableCreateNewGroup = enableCreateNewGroup, + enableFindByUsername = enableFindByUsername, + enableFindByPhoneNumber = enableFindByPhoneNumber + ) }, modifier = modifier ) @@ -141,10 +150,57 @@ private fun RecipientSearchResultsList( } } +private fun ContactSelectionListFragment.setUpCallbacks( + callbacks: RecipientPickerCallbacks, + enableCreateNewGroup: Boolean, + enableFindByUsername: Boolean, + enableFindByPhoneNumber: Boolean +) { + val fragment: ContactSelectionListFragment = this + + if (enableCreateNewGroup) { + fragment.setNewConversationCallback(object : ContactSelectionListFragment.NewConversationCallback { + override fun onInvite() = callbacks.onInviteToSignal() + override fun onNewGroup(forceV1: Boolean) = callbacks.onCreateNewGroup() + }) + } + + if (enableFindByUsername || enableFindByPhoneNumber) { + fragment.setFindByCallback(object : ContactSelectionListFragment.FindByCallback { + override fun onFindByUsername() = callbacks.onFindByUsername() + override fun onFindByPhoneNumber() = callbacks.onFindByPhoneNumber() + }) + } + + fragment.setOnContactSelectedListener(object : ContactSelectionListFragment.OnContactSelectedListener { + override fun onBeforeContactSelected( + isFromUnknownSearchKey: Boolean, + recipientId: Optional, + number: String?, + chatType: Optional, + resultConsumer: Consumer + ) { + val recipientId = recipientId.get() + val shouldAllowSelection = callbacks.shouldAllowSelection(recipientId) + if (shouldAllowSelection) { + callbacks.onRecipientSelected( + id = recipientId, + phone = number?.let(::PhoneNumber) + ) + } + resultConsumer.accept(shouldAllowSelection) + } + + override fun onContactDeselected(recipientId: Optional, number: String?, chatType: Optional) = Unit + override fun onSelectionChanged() = Unit + }) +} + @DayNightPreviews @Composable private fun RecipientPickerPreview() { RecipientPicker( + enableCreateNewGroup = true, enableFindByUsername = true, enableFindByPhoneNumber = true, callbacks = RecipientPickerCallbacks.Empty @@ -152,13 +208,27 @@ private fun RecipientPickerPreview() { } interface RecipientPickerCallbacks { + fun onCreateNewGroup() fun onFindByUsername() fun onFindByPhoneNumber() - fun onRecipientClick(id: RecipientId) + + /** + * Validates whether the selection of [RecipientId] should be allowed. Return true if the selection can proceed, false otherwise. + * + * This is called before [onRecipientSelected] to provide a chance to prevent the selection. + */ + fun shouldAllowSelection(id: RecipientId): Boolean + fun onRecipientSelected(id: RecipientId?, phone: PhoneNumber?) + fun onMessage(id: RecipientId) + fun onInviteToSignal() object Empty : RecipientPickerCallbacks { + override fun onCreateNewGroup() = Unit override fun onFindByUsername() = Unit override fun onFindByPhoneNumber() = Unit - override fun onRecipientClick(id: RecipientId) = Unit + override fun shouldAllowSelection(id: RecipientId): Boolean = true + override fun onRecipientSelected(id: RecipientId?, phone: PhoneNumber?) = Unit + override fun onMessage(id: RecipientId) = Unit + override fun onInviteToSignal() = Unit } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/PhoneNumber.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/PhoneNumber.kt new file mode 100644 index 0000000000..4033513bc7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/PhoneNumber.kt @@ -0,0 +1,14 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.recipients + +import org.thoughtcrime.securesms.util.SignalE164Util + +@JvmInline +value class PhoneNumber(val value: String) { + val displayText: String + get() = SignalE164Util.prettyPrint(value) +}