New conversation v2 - Implement remaining functionality.

This commit is contained in:
jeffrey-signal
2025-10-17 11:04:08 -04:00
committed by Cody Henthorne
parent 802f980c6f
commit 4fd4792dd8
5 changed files with 391 additions and 21 deletions

View File

@@ -158,11 +158,11 @@ public final class ContactSelectionListFragment extends LoggingFragment {
} }
if (getParentFragment() instanceof ScrollCallback) { if (getParentFragment() instanceof ScrollCallback) {
scrollCallback = (ScrollCallback) getParentFragment(); setScrollCallback((ScrollCallback) getParentFragment());
} }
if (context instanceof ScrollCallback) { if (context instanceof ScrollCallback) {
scrollCallback = (ScrollCallback) context; setScrollCallback((ScrollCallback) context);
} }
if (getParentFragment() instanceof OnContactSelectedListener) { if (getParentFragment() instanceof OnContactSelectedListener) {
@@ -190,11 +190,11 @@ public final class ContactSelectionListFragment extends LoggingFragment {
} }
if (context instanceof OnItemLongClickListener) { if (context instanceof OnItemLongClickListener) {
onItemLongClickListener = (OnItemLongClickListener) context; setOnItemLongClickListener((OnItemLongClickListener) context);
} }
if (getParentFragment() instanceof OnItemLongClickListener) { if (getParentFragment() instanceof OnItemLongClickListener) {
onItemLongClickListener = (OnItemLongClickListener) getParentFragment(); setOnItemLongClickListener((OnItemLongClickListener) getParentFragment());
} }
} }
@@ -206,10 +206,18 @@ public final class ContactSelectionListFragment extends LoggingFragment {
this.findByCallback = callback; this.findByCallback = callback;
} }
public void setScrollCallback(@Nullable ScrollCallback callback) {
this.scrollCallback = callback;
}
public void setOnContactSelectedListener(@Nullable OnContactSelectedListener listener) { public void setOnContactSelectedListener(@Nullable OnContactSelectedListener listener) {
this.onContactSelectedListener = listener; this.onContactSelectedListener = listener;
} }
public void setOnItemLongClickListener(@Nullable OnItemLongClickListener listener) {
this.onItemLongClickListener = listener;
}
@Override @Override
public void onActivityCreated(Bundle icicle) { public void onActivityCreated(Bundle icicle) {
super.onActivityCreated(icicle); super.onActivityCreated(icicle);

View File

@@ -18,13 +18,18 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
@@ -32,14 +37,19 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await import kotlinx.coroutines.rx3.await
import org.signal.core.ui.compose.AllDevicePreviews import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.Dialogs import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.DropdownMenus
import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Scaffolds import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.theme.SignalTheme import org.signal.core.ui.compose.theme.SignalTheme
import org.thoughtcrime.securesms.BlockUnblockDialog
import org.thoughtcrime.securesms.PassphraseRequiredActivity import org.thoughtcrime.securesms.PassphraseRequiredActivity
import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.compose.ScreenTitlePane import org.thoughtcrime.securesms.components.compose.ScreenTitlePane
@@ -47,9 +57,11 @@ import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.conversation.NewConversationUiState.UserMessage import org.thoughtcrime.securesms.conversation.NewConversationUiState.UserMessage
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity
import org.thoughtcrime.securesms.recipients.PhoneNumber import org.thoughtcrime.securesms.recipients.PhoneNumber
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.recipients.ui.findby.FindByActivity import org.thoughtcrime.securesms.recipients.ui.findby.FindByActivity
import org.thoughtcrime.securesms.recipients.ui.findby.FindByMode import org.thoughtcrime.securesms.recipients.ui.findby.FindByMode
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.window.AppScaffold import org.thoughtcrime.securesms.window.AppScaffold
import org.thoughtcrime.securesms.window.WindowSizeClass import org.thoughtcrime.securesms.window.WindowSizeClass
import org.thoughtcrime.securesms.window.rememberAppScaffoldNavigator import org.thoughtcrime.securesms.window.rememberAppScaffoldNavigator
@@ -87,7 +99,7 @@ private fun NewConversationScreen(
activityIntent: Intent, activityIntent: Intent,
closeScreen: () -> Unit closeScreen: () -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current as FragmentActivity
val createGroupLauncher: ActivityResultLauncher<Intent> = rememberLauncherForActivityResult( val createGroupLauncher: ActivityResultLauncher<Intent> = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult(), contract = ActivityResultContracts.StartActivityForResult(),
@@ -107,6 +119,7 @@ private fun NewConversationScreen(
} }
) )
val coroutineScope = rememberCoroutineScope()
val callbacks = remember { val callbacks = remember {
object : Callbacks { object : Callbacks {
override fun onCreateNewGroup() = createGroupLauncher.launch(CreateGroupActivity.newIntent(context)) override fun onCreateNewGroup() = createGroupLauncher.launch(CreateGroupActivity.newIntent(context))
@@ -115,8 +128,23 @@ private fun NewConversationScreen(
override fun shouldAllowSelection(id: RecipientId): Boolean = true override fun shouldAllowSelection(id: RecipientId): Boolean = true
override fun onRecipientSelected(id: RecipientId?, phone: PhoneNumber?) = viewModel.onRecipientSelected(id, phone) override fun onRecipientSelected(id: RecipientId?, phone: PhoneNumber?) = viewModel.onRecipientSelected(id, phone)
override fun onMessage(id: RecipientId) = viewModel.onMessage(id) 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 fun onRemove(recipient: Recipient) = viewModel.showRemoveConfirmation(recipient)
override fun onRemoveConfirmed(recipient: Recipient) {
coroutineScope.launch { viewModel.removeRecipient(recipient) }
}
override fun onBlock(recipient: Recipient) = viewModel.showBlockConfirmation(recipient)
override fun onBlockConfirmed(recipient: Recipient) {
coroutineScope.launch { viewModel.blockRecipient(recipient) }
}
override fun onInviteToSignal() = context.startActivity(AppSettingsActivity.invite(context)) override fun onInviteToSignal() = context.startActivity(AppSettingsActivity.invite(context))
override fun onRefresh() = viewModel.refresh()
override fun onUserMessageDismissed(userMessage: UserMessage) = viewModel.onUserMessageDismissed() override fun onUserMessageDismissed(userMessage: UserMessage) = viewModel.onUserMessageDismissed()
override fun onContactsListReset() = viewModel.onContactsListReset()
override fun onBackPressed() = closeScreen() override fun onBackPressed() = closeScreen()
} }
} }
@@ -167,6 +195,7 @@ private fun NewConversationScreenUi(
) { ) {
val windowSizeClass = WindowSizeClass.rememberWindowSizeClass() val windowSizeClass = WindowSizeClass.rememberWindowSizeClass()
val isSplitPane = windowSizeClass.isSplitPane(forceSplitPaneOnCompactLandscape = uiState.forceSplitPaneOnCompactLandscape) val isSplitPane = windowSizeClass.isSplitPane(forceSplitPaneOnCompactLandscape = uiState.forceSplitPaneOnCompactLandscape)
val snackbarHostState = remember { SnackbarHostState() }
AppScaffold( AppScaffold(
topBarContent = { topBarContent = {
@@ -175,7 +204,8 @@ private fun NewConversationScreenUi(
titleContent = { _, title -> Text(text = title, style = MaterialTheme.typography.titleLarge) }, titleContent = { _, title -> Text(text = title, style = MaterialTheme.typography.titleLarge) },
navigationIcon = ImageVector.vectorResource(R.drawable.symbol_arrow_start_24), navigationIcon = ImageVector.vectorResource(R.drawable.symbol_arrow_start_24),
navigationContentDescription = stringResource(R.string.DefaultTopAppBar__navigate_up_content_description), navigationContentDescription = stringResource(R.string.DefaultTopAppBar__navigate_up_content_description),
onNavigationClick = callbacks::onBackPressed onNavigationClick = callbacks::onBackPressed,
actions = { TopAppBarActions(callbacks) }
) )
}, },
@@ -187,6 +217,7 @@ private fun NewConversationScreenUi(
) )
} else { } else {
NewConversationRecipientPicker( NewConversationRecipientPicker(
uiState = uiState,
callbacks = callbacks callbacks = callbacks
) )
} }
@@ -198,12 +229,17 @@ private fun NewConversationScreenUi(
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { ) {
NewConversationRecipientPicker( NewConversationRecipientPicker(
uiState = uiState,
callbacks = callbacks, callbacks = callbacks,
modifier = Modifier.widthIn(max = windowSizeClass.detailPaneMaxContentWidth) modifier = Modifier.widthIn(max = windowSizeClass.detailPaneMaxContentWidth)
) )
} }
}, },
snackbarHost = {
SnackbarHost(snackbarHostState)
},
navigator = rememberAppScaffoldNavigator( navigator = rememberAppScaffoldNavigator(
isSplitPane = isSplitPane isSplitPane = isSplitPane
) )
@@ -211,7 +247,10 @@ private fun NewConversationScreenUi(
UserMessagesHost( UserMessagesHost(
userMessage = uiState.userMessage, userMessage = uiState.userMessage,
onDismiss = callbacks::onUserMessageDismissed onDismiss = callbacks::onUserMessageDismissed,
onRemoveConfirmed = callbacks::onRemoveConfirmed,
onBlockConfirmed = callbacks::onBlockConfirmed,
snackbarHostState = snackbarHostState
) )
if (uiState.isRefreshingRecipient) { if (uiState.isRefreshingRecipient) {
@@ -219,7 +258,54 @@ private fun NewConversationScreenUi(
} }
} }
@Composable
private fun TopAppBarActions(callbacks: Callbacks) {
val menuController = remember { DropdownMenus.MenuController() }
IconButton(
onClick = { menuController.show() },
modifier = Modifier.padding(horizontal = 8.dp)
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.symbol_more_vertical),
contentDescription = stringResource(R.string.NewConversationActivity__accessibility_open_top_bar_menu)
)
}
DropdownMenus.Menu(
controller = menuController,
offsetX = 24.dp,
offsetY = 0.dp,
modifier = Modifier
) {
DropdownMenus.Item(
text = { Text(text = stringResource(R.string.new_conversation_activity__refresh)) },
onClick = {
callbacks.onRefresh()
menuController.hide()
}
)
DropdownMenus.Item(
text = { Text(text = stringResource(R.string.text_secure_normal__menu_new_group)) },
onClick = {
callbacks.onCreateNewGroup()
menuController.hide()
}
)
DropdownMenus.Item(
text = { Text(text = stringResource(R.string.text_secure_normal__invite_friends)) },
onClick = {
callbacks.onInviteToSignal()
menuController.hide()
}
)
}
}
private interface Callbacks : RecipientPickerCallbacks { private interface Callbacks : RecipientPickerCallbacks {
fun onRemoveConfirmed(recipient: Recipient)
fun onBlockConfirmed(recipient: Recipient)
fun onUserMessageDismissed(userMessage: UserMessage) fun onUserMessageDismissed(userMessage: UserMessage)
fun onBackPressed() fun onBackPressed()
@@ -230,7 +316,15 @@ private interface Callbacks : RecipientPickerCallbacks {
override fun shouldAllowSelection(id: RecipientId): Boolean = true override fun shouldAllowSelection(id: RecipientId): Boolean = true
override fun onRecipientSelected(id: RecipientId?, phone: PhoneNumber?) = Unit override fun onRecipientSelected(id: RecipientId?, phone: PhoneNumber?) = Unit
override fun onMessage(id: RecipientId) = Unit override fun onMessage(id: RecipientId) = Unit
override fun onVoiceCall(recipient: Recipient) = Unit
override fun onVideoCall(recipient: Recipient) = Unit
override fun onRemove(recipient: Recipient) = Unit
override fun onRemoveConfirmed(recipient: Recipient) = Unit
override fun onBlock(recipient: Recipient) = Unit
override fun onBlockConfirmed(recipient: Recipient) = Unit
override fun onInviteToSignal() = Unit override fun onInviteToSignal() = Unit
override fun onRefresh() = Unit
override fun onContactsListReset() = Unit
override fun onUserMessageDismissed(userMessage: UserMessage) = Unit override fun onUserMessageDismissed(userMessage: UserMessage) = Unit
override fun onBackPressed() = Unit override fun onBackPressed() = Unit
} }
@@ -238,6 +332,7 @@ private interface Callbacks : RecipientPickerCallbacks {
@Composable @Composable
private fun NewConversationRecipientPicker( private fun NewConversationRecipientPicker(
uiState: NewConversationUiState,
callbacks: Callbacks, callbacks: Callbacks,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
@@ -245,6 +340,8 @@ private fun NewConversationRecipientPicker(
enableCreateNewGroup = true, enableCreateNewGroup = true,
enableFindByUsername = true, enableFindByUsername = true,
enableFindByPhoneNumber = true, enableFindByPhoneNumber = true,
isRefreshing = uiState.isRefreshingContacts,
shouldResetContactsList = uiState.shouldResetContactsList,
callbacks = callbacks, callbacks = callbacks,
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
@@ -255,22 +352,66 @@ private fun NewConversationRecipientPicker(
@Composable @Composable
private fun UserMessagesHost( private fun UserMessagesHost(
userMessage: UserMessage?, userMessage: UserMessage?,
onDismiss: (UserMessage) -> Unit onDismiss: (UserMessage) -> Unit,
onBlockConfirmed: (Recipient) -> Unit,
onRemoveConfirmed: (Recipient) -> Unit,
snackbarHostState: SnackbarHostState
) { ) {
val context = LocalContext.current
when (userMessage) { when (userMessage) {
null -> {} null -> {}
UserMessage.NetworkError -> Dialogs.SimpleMessageDialog( is UserMessage.Info.RecipientRemoved -> LaunchedEffect(userMessage) {
snackbarHostState.showSnackbar(
message = context.getString(R.string.NewConversationActivity__s_has_been_removed, userMessage.recipient.getDisplayName(context))
)
onDismiss(userMessage)
}
is UserMessage.Info.RecipientBlocked -> LaunchedEffect(userMessage) {
snackbarHostState.showSnackbar(
message = context.getString(R.string.NewConversationActivity__s_has_been_blocked, userMessage.recipient.getDisplayName(context))
)
onDismiss(userMessage)
}
is UserMessage.Info.NetworkError -> Dialogs.SimpleMessageDialog(
message = stringResource(R.string.NetworkFailure__network_error_check_your_connection_and_try_again), message = stringResource(R.string.NetworkFailure__network_error_check_your_connection_and_try_again),
dismiss = stringResource(android.R.string.ok), dismiss = stringResource(android.R.string.ok),
onDismiss = { onDismiss(userMessage) } onDismiss = { onDismiss(userMessage) }
) )
is UserMessage.RecipientNotSignalUser -> Dialogs.SimpleMessageDialog( is UserMessage.Info.RecipientNotSignalUser -> Dialogs.SimpleMessageDialog(
message = stringResource(R.string.NewConversationActivity__s_is_not_a_signal_user, userMessage.phone!!.displayText), message = stringResource(R.string.NewConversationActivity__s_is_not_a_signal_user, userMessage.phone!!.displayText),
dismiss = stringResource(android.R.string.ok), dismiss = stringResource(android.R.string.ok),
onDismiss = { onDismiss(userMessage) } onDismiss = { onDismiss(userMessage) }
) )
is UserMessage.Info.UserAlreadyInAnotherCall -> LaunchedEffect(userMessage) {
snackbarHostState.showSnackbar(
message = context.getString(R.string.CommunicationActions__you_are_already_in_a_call)
)
onDismiss(userMessage)
}
is UserMessage.Prompt.ConfirmRemoveRecipient -> Dialogs.SimpleAlertDialog(
title = stringResource(R.string.NewConversationActivity__remove_s, userMessage.recipient.getShortDisplayName(context)),
body = stringResource(R.string.NewConversationActivity__you_wont_see_this_person),
confirm = stringResource(R.string.NewConversationActivity__remove),
dismiss = stringResource(android.R.string.cancel),
onConfirm = { onRemoveConfirmed(userMessage.recipient) },
onDismiss = { onDismiss(userMessage) }
)
is UserMessage.Prompt.ConfirmBlockRecipient -> {
val lifecycle = LocalLifecycleOwner.current.lifecycle
LaunchedEffect(userMessage.recipient) {
BlockUnblockDialog.showBlockFor(context, lifecycle, userMessage.recipient) {
onBlockConfirmed(userMessage.recipient)
}
}
}
} }
} }

View File

@@ -13,12 +13,17 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.signal.core.util.logging.Log import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.conversation.NewConversationUiState.UserMessage import org.thoughtcrime.securesms.contacts.management.ContactsManagementRepository
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery
import org.thoughtcrime.securesms.conversation.NewConversationUiState.UserMessage.Info
import org.thoughtcrime.securesms.conversation.NewConversationUiState.UserMessage.Prompt
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.PhoneNumber import org.thoughtcrime.securesms.recipients.PhoneNumber
import org.thoughtcrime.securesms.recipients.Recipient.Companion.resolved import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.recipients.RecipientRepository import org.thoughtcrime.securesms.recipients.RecipientRepository
@@ -30,6 +35,8 @@ class NewConversationViewModel : ViewModel() {
private val _uiState = MutableStateFlow(NewConversationUiState()) private val _uiState = MutableStateFlow(NewConversationUiState())
val uiState: StateFlow<NewConversationUiState> = _uiState.asStateFlow() val uiState: StateFlow<NewConversationUiState> = _uiState.asStateFlow()
private val contactsManagementRepo = ContactsManagementRepository(AppDependencies.application)
fun onMessage(id: RecipientId): Unit = openConversation(recipientId = id) fun onMessage(id: RecipientId): Unit = openConversation(recipientId = id)
fun onRecipientSelected(id: RecipientId?, phone: PhoneNumber?) { fun onRecipientSelected(id: RecipientId?, phone: PhoneNumber?) {
@@ -63,7 +70,7 @@ class NewConversationViewModel : ViewModel() {
when (lookupResult) { when (lookupResult) {
is RecipientRepository.LookupResult.Success -> { is RecipientRepository.LookupResult.Success -> {
val recipient = resolved(lookupResult.recipientId) val recipient = Recipient.resolved(lookupResult.recipientId)
_uiState.update { it.copy(isRefreshingRecipient = false) } _uiState.update { it.copy(isRefreshingRecipient = false) }
if (recipient.isRegistered && recipient.hasServiceId) { if (recipient.isRegistered && recipient.hasServiceId) {
@@ -77,7 +84,7 @@ class NewConversationViewModel : ViewModel() {
_uiState.update { _uiState.update {
it.copy( it.copy(
isRefreshingRecipient = false, isRefreshingRecipient = false,
userMessage = UserMessage.RecipientNotSignalUser(phone) userMessage = Info.RecipientNotSignalUser(phone)
) )
} }
} }
@@ -86,7 +93,7 @@ class NewConversationViewModel : ViewModel() {
_uiState.update { _uiState.update {
it.copy( it.copy(
isRefreshingRecipient = false, isRefreshingRecipient = false,
userMessage = UserMessage.NetworkError userMessage = Info.NetworkError
) )
} }
} }
@@ -94,6 +101,62 @@ class NewConversationViewModel : ViewModel() {
} }
} }
fun showRemoveConfirmation(recipient: Recipient) {
_uiState.update {
it.copy(userMessage = Prompt.ConfirmRemoveRecipient(recipient))
}
}
suspend fun removeRecipient(recipient: Recipient) {
contactsManagementRepo.hideContact(recipient).await()
refresh()
_uiState.update {
it.copy(
shouldResetContactsList = true,
userMessage = Info.RecipientRemoved(recipient)
)
}
}
fun showBlockConfirmation(recipient: Recipient) {
_uiState.update {
it.copy(userMessage = Prompt.ConfirmBlockRecipient(recipient))
}
}
suspend fun blockRecipient(recipient: Recipient) {
contactsManagementRepo.blockContact(recipient).await()
refresh()
_uiState.update {
it.copy(
shouldResetContactsList = true,
userMessage = Info.RecipientBlocked(recipient)
)
}
}
fun onUserAlreadyInACall() {
_uiState.update { it.copy(userMessage = Info.UserAlreadyInAnotherCall) }
}
fun onContactsListReset() {
_uiState.update { it.copy(shouldResetContactsList = false) }
}
fun refresh() {
viewModelScope.launch {
_uiState.update { it.copy(isRefreshingContacts = true) }
withContext(Dispatchers.IO) {
ContactDiscovery.refreshAll(AppDependencies.application, true)
}
_uiState.update { it.copy(isRefreshingContacts = false) }
}
}
fun onUserMessageDismissed() { fun onUserMessageDismissed() {
_uiState.update { it.copy(userMessage = null) } _uiState.update { it.copy(userMessage = null) }
} }
@@ -102,11 +165,23 @@ class NewConversationViewModel : ViewModel() {
data class NewConversationUiState( data class NewConversationUiState(
val forceSplitPaneOnCompactLandscape: Boolean = SignalStore.internal.forceSplitPaneOnCompactLandscape, val forceSplitPaneOnCompactLandscape: Boolean = SignalStore.internal.forceSplitPaneOnCompactLandscape,
val isRefreshingRecipient: Boolean = false, val isRefreshingRecipient: Boolean = false,
val isRefreshingContacts: Boolean = false,
val shouldResetContactsList: Boolean = false,
val pendingDestination: RecipientId? = null, val pendingDestination: RecipientId? = null,
val userMessage: UserMessage? = null val userMessage: UserMessage? = null
) { ) {
sealed interface UserMessage { sealed interface UserMessage {
data class RecipientNotSignalUser(val phone: PhoneNumber?) : UserMessage sealed interface Info : UserMessage {
data object NetworkError : UserMessage data class RecipientRemoved(val recipient: Recipient) : Info
data class RecipientBlocked(val recipient: Recipient) : Info
data class RecipientNotSignalUser(val phone: PhoneNumber?) : Info
data object UserAlreadyInAnotherCall : Info
data object NetworkError : Info
}
sealed interface Prompt : UserMessage {
data class ConfirmRemoveRecipient(val recipient: Recipient) : Prompt
data class ConfirmBlockRecipient(val recipient: Recipient) : Prompt
}
} }
} }

View File

@@ -5,6 +5,8 @@
package org.thoughtcrime.securesms.conversation package org.thoughtcrime.securesms.conversation
import android.view.View
import android.view.ViewGroup
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -16,21 +18,35 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.fragment.compose.rememberFragmentState import androidx.fragment.compose.rememberFragmentState
import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.core.ui.compose.DayNightPreviews import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Fragments import org.signal.core.ui.compose.Fragments
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.ContactSelectionListFragment import org.thoughtcrime.securesms.ContactSelectionListFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ContactFilterView 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.paged.ChatType 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.contacts.selection.ContactSelectionArguments
import org.thoughtcrime.securesms.recipients.PhoneNumber import org.thoughtcrime.securesms.recipients.PhoneNumber
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.ViewUtil
import java.util.Optional import java.util.Optional
import java.util.function.Consumer import java.util.function.Consumer
@@ -42,6 +58,9 @@ fun RecipientPicker(
enableCreateNewGroup: Boolean, enableCreateNewGroup: Boolean,
enableFindByUsername: Boolean, enableFindByUsername: Boolean,
enableFindByPhoneNumber: Boolean, enableFindByPhoneNumber: Boolean,
isRefreshing: Boolean,
focusAndShowKeyboard: Boolean = LocalConfiguration.current.screenHeightDp.dp > 600.dp,
shouldResetContactsList: Boolean,
callbacks: RecipientPickerCallbacks, callbacks: RecipientPickerCallbacks,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
@@ -54,6 +73,7 @@ fun RecipientPicker(
onFilterChanged = { filter -> onFilterChanged = { filter ->
searchQuery = filter searchQuery = filter
}, },
focusAndShowKeyboard = focusAndShowKeyboard,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
@@ -64,6 +84,8 @@ fun RecipientPicker(
enableCreateNewGroup = enableCreateNewGroup, enableCreateNewGroup = enableCreateNewGroup,
enableFindByUsername = enableFindByUsername, enableFindByUsername = enableFindByUsername,
enableFindByPhoneNumber = enableFindByPhoneNumber, enableFindByPhoneNumber = enableFindByPhoneNumber,
isRefreshing = isRefreshing,
shouldResetContactsList = shouldResetContactsList,
callbacks = callbacks, callbacks = callbacks,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -81,6 +103,7 @@ fun RecipientPicker(
private fun RecipientSearchField( private fun RecipientSearchField(
onFilterChanged: (String) -> Unit, onFilterChanged: (String) -> Unit,
@StringRes hintText: Int? = null, @StringRes hintText: Int? = null,
focusAndShowKeyboard: Boolean = false,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val context = LocalContext.current val context = LocalContext.current
@@ -90,6 +113,17 @@ private fun RecipientSearchField(
} }
} }
// 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) {
if (focusAndShowKeyboard) {
wrappedView.focusAndShowKeyboard()
} else {
wrappedView.clearFocus()
ViewUtil.hideKeyboard(wrappedView.context, wrappedView)
}
}
DisposableEffect(onFilterChanged) { DisposableEffect(onFilterChanged) {
wrappedView.setOnFilterChangedListener { filter -> onFilterChanged(filter) } wrappedView.setOnFilterChangedListener { filter -> onFilterChanged(filter) }
onDispose { onDispose {
@@ -109,6 +143,8 @@ private fun RecipientSearchResultsList(
enableCreateNewGroup: Boolean, enableCreateNewGroup: Boolean,
enableFindByUsername: Boolean, enableFindByUsername: Boolean,
enableFindByPhoneNumber: Boolean, enableFindByPhoneNumber: Boolean,
isRefreshing: Boolean,
shouldResetContactsList: Boolean,
callbacks: RecipientPickerCallbacks, callbacks: RecipientPickerCallbacks,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
@@ -120,6 +156,7 @@ private fun RecipientSearchResultsList(
val fragmentState = rememberFragmentState() val fragmentState = rememberFragmentState()
var currentFragment by remember { mutableStateOf<ContactSelectionListFragment?>(null) } var currentFragment by remember { mutableStateOf<ContactSelectionListFragment?>(null) }
val coroutineScope = rememberCoroutineScope()
Fragments.Fragment<ContactSelectionListFragment>( Fragments.Fragment<ContactSelectionListFragment>(
arguments = fragmentArgs, arguments = fragmentArgs,
@@ -131,7 +168,8 @@ private fun RecipientSearchResultsList(
callbacks = callbacks, callbacks = callbacks,
enableCreateNewGroup = enableCreateNewGroup, enableCreateNewGroup = enableCreateNewGroup,
enableFindByUsername = enableFindByUsername, enableFindByUsername = enableFindByUsername,
enableFindByPhoneNumber = enableFindByPhoneNumber enableFindByPhoneNumber = enableFindByPhoneNumber,
coroutineScope = coroutineScope
) )
}, },
modifier = modifier modifier = modifier
@@ -148,13 +186,30 @@ private fun RecipientSearchResultsList(
previousQueryText = searchQuery previousQueryText = searchQuery
} }
} }
var wasRefreshing by rememberSaveable { mutableStateOf(isRefreshing) }
LaunchedEffect(isRefreshing) {
currentFragment?.isRefreshing = isRefreshing
if (wasRefreshing && !isRefreshing) {
currentFragment?.onDataRefreshed()
}
wasRefreshing = isRefreshing
}
LaunchedEffect(shouldResetContactsList) {
if (shouldResetContactsList) {
currentFragment?.reset()
callbacks.onContactsListReset()
}
}
} }
private fun ContactSelectionListFragment.setUpCallbacks( private fun ContactSelectionListFragment.setUpCallbacks(
callbacks: RecipientPickerCallbacks, callbacks: RecipientPickerCallbacks,
enableCreateNewGroup: Boolean, enableCreateNewGroup: Boolean,
enableFindByUsername: Boolean, enableFindByUsername: Boolean,
enableFindByPhoneNumber: Boolean enableFindByPhoneNumber: Boolean,
coroutineScope: CoroutineScope
) { ) {
val fragment: ContactSelectionListFragment = this val fragment: ContactSelectionListFragment = this
@@ -194,6 +249,83 @@ private fun ContactSelectionListFragment.setUpCallbacks(
override fun onContactDeselected(recipientId: Optional<RecipientId?>, number: String?, chatType: Optional<ChatType?>) = Unit override fun onContactDeselected(recipientId: Optional<RecipientId?>, number: String?, chatType: Optional<ChatType?>) = Unit
override fun onSelectionChanged() = Unit override fun onSelectionChanged() = Unit
}) })
fragment.setOnItemLongClickListener { anchorView, contactSearchKey, recyclerView ->
coroutineScope.launch { showItemContextMenu(anchorView, contactSearchKey, recyclerView, callbacks) }
true
}
fragment.setOnRefreshListener(callbacks::onRefresh)
fragment.setScrollCallback {
fragment.view?.let { view -> ViewUtil.hideKeyboard(view.context, view) }
}
}
private suspend fun showItemContextMenu(anchorView: View, contactSearchKey: ContactSearchKey, recyclerView: RecyclerView, callbacks: RecipientPickerCallbacks) {
val context = anchorView.context
val recipient = withContext(Dispatchers.IO) {
Recipient.resolved(contactSearchKey.requireRecipientSearchKey().recipientId)
}
val actions = buildList {
val messageItem = ActionItem(
iconRes = R.drawable.ic_chat_message_24,
title = context.getString(R.string.NewConversationActivity__message),
tintRes = R.color.signal_colorOnSurface,
action = { callbacks.onMessage(recipient.id) }
)
add(messageItem)
if (!recipient.isSelf && !recipient.isGroup && recipient.isRegistered) {
val voiceCallItem = ActionItem(
iconRes = R.drawable.ic_phone_right_24,
title = context.getString(R.string.NewConversationActivity__audio_call),
tintRes = R.color.signal_colorOnSurface,
action = { callbacks.onVoiceCall(recipient) }
)
add(voiceCallItem)
}
if (!recipient.isSelf && !recipient.isMmsGroup && recipient.isRegistered) {
val videoCallItem = ActionItem(
iconRes = R.drawable.ic_video_call_24,
title = context.getString(R.string.NewConversationActivity__video_call),
tintRes = R.color.signal_colorOnSurface,
action = { callbacks.onVideoCall(recipient) }
)
add(videoCallItem)
}
if (!recipient.isSelf && !recipient.isGroup) {
val removeItem = ActionItem(
iconRes = R.drawable.ic_minus_circle_20, // TODO [alex] -- correct asset
title = context.getString(R.string.NewConversationActivity__remove),
tintRes = R.color.signal_colorOnSurface,
action = { callbacks.onRemove(recipient) }
)
add(removeItem)
}
if (!recipient.isSelf) {
val blockItem = ActionItem(
iconRes = R.drawable.ic_block_tinted_24,
title = context.getString(R.string.NewConversationActivity__block),
tintRes = R.color.signal_colorError,
action = { callbacks.onBlock(recipient) }
)
add(blockItem)
}
}
SignalContextMenu.Builder(anchorView, anchorView.getRootView() as ViewGroup)
.preferredVerticalPosition(SignalContextMenu.VerticalPosition.BELOW)
.preferredHorizontalPosition(SignalContextMenu.HorizontalPosition.START)
.offsetX(DimensionUnit.DP.toPixels(12f).toInt())
.offsetY(DimensionUnit.DP.toPixels(12f).toInt())
.onDismiss { recyclerView.suppressLayout(false) }
.show(actions)
recyclerView.suppressLayout(true)
} }
@DayNightPreviews @DayNightPreviews
@@ -203,6 +335,8 @@ private fun RecipientPickerPreview() {
enableCreateNewGroup = true, enableCreateNewGroup = true,
enableFindByUsername = true, enableFindByUsername = true,
enableFindByPhoneNumber = true, enableFindByPhoneNumber = true,
isRefreshing = false,
shouldResetContactsList = false,
callbacks = RecipientPickerCallbacks.Empty callbacks = RecipientPickerCallbacks.Empty
) )
} }
@@ -220,7 +354,13 @@ interface RecipientPickerCallbacks {
fun shouldAllowSelection(id: RecipientId): Boolean fun shouldAllowSelection(id: RecipientId): Boolean
fun onRecipientSelected(id: RecipientId?, phone: PhoneNumber?) fun onRecipientSelected(id: RecipientId?, phone: PhoneNumber?)
fun onMessage(id: RecipientId) fun onMessage(id: RecipientId)
fun onVoiceCall(recipient: Recipient)
fun onVideoCall(recipient: Recipient)
fun onRemove(recipient: Recipient)
fun onBlock(recipient: Recipient)
fun onInviteToSignal() fun onInviteToSignal()
fun onRefresh()
fun onContactsListReset()
object Empty : RecipientPickerCallbacks { object Empty : RecipientPickerCallbacks {
override fun onCreateNewGroup() = Unit override fun onCreateNewGroup() = Unit
@@ -229,6 +369,12 @@ interface RecipientPickerCallbacks {
override fun shouldAllowSelection(id: RecipientId): Boolean = true override fun shouldAllowSelection(id: RecipientId): Boolean = true
override fun onRecipientSelected(id: RecipientId?, phone: PhoneNumber?) = Unit override fun onRecipientSelected(id: RecipientId?, phone: PhoneNumber?) = Unit
override fun onMessage(id: RecipientId) = Unit override fun onMessage(id: RecipientId) = Unit
override fun onVoiceCall(recipient: Recipient) = Unit
override fun onVideoCall(recipient: Recipient) = Unit
override fun onRemove(recipient: Recipient) = Unit
override fun onBlock(recipient: Recipient) = Unit
override fun onInviteToSignal() = Unit override fun onInviteToSignal() = Unit
override fun onRefresh() = Unit
override fun onContactsListReset() = Unit
} }
} }

View File

@@ -5918,8 +5918,8 @@
<string name="NewConversationActivity__view_contact">View contact</string> <string name="NewConversationActivity__view_contact">View contact</string>
<!-- Error message shown when looking up a person by phone number and that phone number is not associated with a signal account --> <!-- Error message shown when looking up a person by phone number and that phone number is not associated with a signal account -->
<string name="NewConversationActivity__s_is_not_a_signal_user">%1$s is not a Signal user</string> <string name="NewConversationActivity__s_is_not_a_signal_user">%1$s is not a Signal user</string>
<!-- Error message shown when we could not get a user from the username link --> <!-- Accessibility label for opening the screen specific dropdown menu. -->
<string name="NewConversationActivity__">%1$s is not a Signal user</string> <string name="NewConversationActivity__accessibility_open_top_bar_menu">Open menu</string>
<!-- Error message shown in a dialog when trying to create a new group with non-signal users (e.g., unregistered or phone number only contacts) --> <!-- Error message shown in a dialog when trying to create a new group with non-signal users (e.g., unregistered or phone number only contacts) -->
<plurals name="CreateGroupActivity_not_signal_users"> <plurals name="CreateGroupActivity_not_signal_users">
<item quantity="one">%1$s is not a Signal user</item> <item quantity="one">%1$s is not a Signal user</item>