New conversation v2 - Add support for find by username/phone/contacts and group creation.

This commit is contained in:
jeffrey-signal
2025-10-15 09:32:47 -04:00
committed by Cody Henthorne
parent 33f9369883
commit 5d60ab35de
6 changed files with 349 additions and 30 deletions

View File

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

View File

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

View File

@@ -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<Intent> = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult(),
onResult = { result ->
if (result.resultCode == RESULT_OK) {
closeScreen()
}
}
)
val findByLauncher: ActivityResultLauncher<FindByMode> = 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
),

View File

@@ -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<NewConversationUiState> = _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
}
}

View File

@@ -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<RecipientId?>,
number: String?,
chatType: Optional<ChatType?>,
resultConsumer: Consumer<Boolean?>
) {
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<RecipientId?>, number: String?, chatType: Optional<ChatType?>) = 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
}
}

View File

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