mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-24 04:58:45 +00:00
New conversation v2 - Implement remaining functionality.
This commit is contained in:
committed by
Cody Henthorne
parent
802f980c6f
commit
4fd4792dd8
@@ -158,11 +158,11 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
}
|
||||
|
||||
if (getParentFragment() instanceof ScrollCallback) {
|
||||
scrollCallback = (ScrollCallback) getParentFragment();
|
||||
setScrollCallback((ScrollCallback) getParentFragment());
|
||||
}
|
||||
|
||||
if (context instanceof ScrollCallback) {
|
||||
scrollCallback = (ScrollCallback) context;
|
||||
setScrollCallback((ScrollCallback) context);
|
||||
}
|
||||
|
||||
if (getParentFragment() instanceof OnContactSelectedListener) {
|
||||
@@ -190,11 +190,11 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
}
|
||||
|
||||
if (context instanceof OnItemLongClickListener) {
|
||||
onItemLongClickListener = (OnItemLongClickListener) context;
|
||||
setOnItemLongClickListener((OnItemLongClickListener) context);
|
||||
}
|
||||
|
||||
if (getParentFragment() instanceof OnItemLongClickListener) {
|
||||
onItemLongClickListener = (OnItemLongClickListener) getParentFragment();
|
||||
setOnItemLongClickListener((OnItemLongClickListener) getParentFragment());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,10 +206,18 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
this.findByCallback = callback;
|
||||
}
|
||||
|
||||
public void setScrollCallback(@Nullable ScrollCallback callback) {
|
||||
this.scrollCallback = callback;
|
||||
}
|
||||
|
||||
public void setOnContactSelectedListener(@Nullable OnContactSelectedListener listener) {
|
||||
this.onContactSelectedListener = listener;
|
||||
}
|
||||
|
||||
public void setOnItemLongClickListener(@Nullable OnItemLongClickListener listener) {
|
||||
this.onItemLongClickListener = listener;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActivityCreated(Bundle icicle) {
|
||||
super.onActivityCreated(icicle);
|
||||
|
||||
@@ -18,13 +18,18 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.vectorResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import kotlinx.coroutines.launch
|
||||
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.DropdownMenus
|
||||
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.BlockUnblockDialog
|
||||
import org.thoughtcrime.securesms.PassphraseRequiredActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
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.groups.ui.creategroup.CreateGroupActivity
|
||||
import org.thoughtcrime.securesms.recipients.PhoneNumber
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
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.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.window.AppScaffold
|
||||
import org.thoughtcrime.securesms.window.WindowSizeClass
|
||||
import org.thoughtcrime.securesms.window.rememberAppScaffoldNavigator
|
||||
@@ -87,7 +99,7 @@ private fun NewConversationScreen(
|
||||
activityIntent: Intent,
|
||||
closeScreen: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val context = LocalContext.current as FragmentActivity
|
||||
|
||||
val createGroupLauncher: ActivityResultLauncher<Intent> = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.StartActivityForResult(),
|
||||
@@ -107,6 +119,7 @@ private fun NewConversationScreen(
|
||||
}
|
||||
)
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val callbacks = remember {
|
||||
object : Callbacks {
|
||||
override fun onCreateNewGroup() = createGroupLauncher.launch(CreateGroupActivity.newIntent(context))
|
||||
@@ -115,8 +128,23 @@ private fun NewConversationScreen(
|
||||
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 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 onRefresh() = viewModel.refresh()
|
||||
override fun onUserMessageDismissed(userMessage: UserMessage) = viewModel.onUserMessageDismissed()
|
||||
override fun onContactsListReset() = viewModel.onContactsListReset()
|
||||
override fun onBackPressed() = closeScreen()
|
||||
}
|
||||
}
|
||||
@@ -167,6 +195,7 @@ private fun NewConversationScreenUi(
|
||||
) {
|
||||
val windowSizeClass = WindowSizeClass.rememberWindowSizeClass()
|
||||
val isSplitPane = windowSizeClass.isSplitPane(forceSplitPaneOnCompactLandscape = uiState.forceSplitPaneOnCompactLandscape)
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
AppScaffold(
|
||||
topBarContent = {
|
||||
@@ -175,7 +204,8 @@ private fun NewConversationScreenUi(
|
||||
titleContent = { _, title -> Text(text = title, style = MaterialTheme.typography.titleLarge) },
|
||||
navigationIcon = ImageVector.vectorResource(R.drawable.symbol_arrow_start_24),
|
||||
navigationContentDescription = stringResource(R.string.DefaultTopAppBar__navigate_up_content_description),
|
||||
onNavigationClick = callbacks::onBackPressed
|
||||
onNavigationClick = callbacks::onBackPressed,
|
||||
actions = { TopAppBarActions(callbacks) }
|
||||
)
|
||||
},
|
||||
|
||||
@@ -187,6 +217,7 @@ private fun NewConversationScreenUi(
|
||||
)
|
||||
} else {
|
||||
NewConversationRecipientPicker(
|
||||
uiState = uiState,
|
||||
callbacks = callbacks
|
||||
)
|
||||
}
|
||||
@@ -198,12 +229,17 @@ private fun NewConversationScreenUi(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
NewConversationRecipientPicker(
|
||||
uiState = uiState,
|
||||
callbacks = callbacks,
|
||||
modifier = Modifier.widthIn(max = windowSizeClass.detailPaneMaxContentWidth)
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
snackbarHost = {
|
||||
SnackbarHost(snackbarHostState)
|
||||
},
|
||||
|
||||
navigator = rememberAppScaffoldNavigator(
|
||||
isSplitPane = isSplitPane
|
||||
)
|
||||
@@ -211,7 +247,10 @@ private fun NewConversationScreenUi(
|
||||
|
||||
UserMessagesHost(
|
||||
userMessage = uiState.userMessage,
|
||||
onDismiss = callbacks::onUserMessageDismissed
|
||||
onDismiss = callbacks::onUserMessageDismissed,
|
||||
onRemoveConfirmed = callbacks::onRemoveConfirmed,
|
||||
onBlockConfirmed = callbacks::onBlockConfirmed,
|
||||
snackbarHostState = snackbarHostState
|
||||
)
|
||||
|
||||
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 {
|
||||
fun onRemoveConfirmed(recipient: Recipient)
|
||||
fun onBlockConfirmed(recipient: Recipient)
|
||||
fun onUserMessageDismissed(userMessage: UserMessage)
|
||||
fun onBackPressed()
|
||||
|
||||
@@ -230,7 +316,15 @@ private interface Callbacks : RecipientPickerCallbacks {
|
||||
override fun shouldAllowSelection(id: RecipientId): Boolean = true
|
||||
override fun onRecipientSelected(id: RecipientId?, phone: PhoneNumber?) = 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 onRefresh() = Unit
|
||||
override fun onContactsListReset() = Unit
|
||||
override fun onUserMessageDismissed(userMessage: UserMessage) = Unit
|
||||
override fun onBackPressed() = Unit
|
||||
}
|
||||
@@ -238,6 +332,7 @@ private interface Callbacks : RecipientPickerCallbacks {
|
||||
|
||||
@Composable
|
||||
private fun NewConversationRecipientPicker(
|
||||
uiState: NewConversationUiState,
|
||||
callbacks: Callbacks,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
@@ -245,6 +340,8 @@ private fun NewConversationRecipientPicker(
|
||||
enableCreateNewGroup = true,
|
||||
enableFindByUsername = true,
|
||||
enableFindByPhoneNumber = true,
|
||||
isRefreshing = uiState.isRefreshingContacts,
|
||||
shouldResetContactsList = uiState.shouldResetContactsList,
|
||||
callbacks = callbacks,
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
@@ -255,22 +352,66 @@ private fun NewConversationRecipientPicker(
|
||||
@Composable
|
||||
private fun UserMessagesHost(
|
||||
userMessage: UserMessage?,
|
||||
onDismiss: (UserMessage) -> Unit
|
||||
onDismiss: (UserMessage) -> Unit,
|
||||
onBlockConfirmed: (Recipient) -> Unit,
|
||||
onRemoveConfirmed: (Recipient) -> Unit,
|
||||
snackbarHostState: SnackbarHostState
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
when (userMessage) {
|
||||
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),
|
||||
dismiss = stringResource(android.R.string.ok),
|
||||
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),
|
||||
dismiss = stringResource(android.R.string.ok),
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,12 +13,17 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.rx3.await
|
||||
import kotlinx.coroutines.withContext
|
||||
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.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.RecipientRepository
|
||||
|
||||
@@ -30,6 +35,8 @@ class NewConversationViewModel : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(NewConversationUiState())
|
||||
val uiState: StateFlow<NewConversationUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val contactsManagementRepo = ContactsManagementRepository(AppDependencies.application)
|
||||
|
||||
fun onMessage(id: RecipientId): Unit = openConversation(recipientId = id)
|
||||
|
||||
fun onRecipientSelected(id: RecipientId?, phone: PhoneNumber?) {
|
||||
@@ -63,7 +70,7 @@ class NewConversationViewModel : ViewModel() {
|
||||
|
||||
when (lookupResult) {
|
||||
is RecipientRepository.LookupResult.Success -> {
|
||||
val recipient = resolved(lookupResult.recipientId)
|
||||
val recipient = Recipient.resolved(lookupResult.recipientId)
|
||||
_uiState.update { it.copy(isRefreshingRecipient = false) }
|
||||
|
||||
if (recipient.isRegistered && recipient.hasServiceId) {
|
||||
@@ -77,7 +84,7 @@ class NewConversationViewModel : ViewModel() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isRefreshingRecipient = false,
|
||||
userMessage = UserMessage.RecipientNotSignalUser(phone)
|
||||
userMessage = Info.RecipientNotSignalUser(phone)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -86,7 +93,7 @@ class NewConversationViewModel : ViewModel() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
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() {
|
||||
_uiState.update { it.copy(userMessage = null) }
|
||||
}
|
||||
@@ -102,11 +165,23 @@ class NewConversationViewModel : ViewModel() {
|
||||
data class NewConversationUiState(
|
||||
val forceSplitPaneOnCompactLandscape: Boolean = SignalStore.internal.forceSplitPaneOnCompactLandscape,
|
||||
val isRefreshingRecipient: Boolean = false,
|
||||
val isRefreshingContacts: Boolean = false,
|
||||
val shouldResetContactsList: Boolean = false,
|
||||
val pendingDestination: RecipientId? = null,
|
||||
val userMessage: UserMessage? = null
|
||||
) {
|
||||
sealed interface UserMessage {
|
||||
data class RecipientNotSignalUser(val phone: PhoneNumber?) : UserMessage
|
||||
data object NetworkError : UserMessage
|
||||
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 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
package org.thoughtcrime.securesms.conversation
|
||||
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -16,21 +18,35 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
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.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
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.Fragments
|
||||
import org.signal.core.util.DimensionUnit
|
||||
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.paged.ChatType
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
|
||||
import org.thoughtcrime.securesms.contacts.selection.ContactSelectionArguments
|
||||
import org.thoughtcrime.securesms.recipients.PhoneNumber
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import java.util.Optional
|
||||
import java.util.function.Consumer
|
||||
|
||||
@@ -42,6 +58,9 @@ fun RecipientPicker(
|
||||
enableCreateNewGroup: Boolean,
|
||||
enableFindByUsername: Boolean,
|
||||
enableFindByPhoneNumber: Boolean,
|
||||
isRefreshing: Boolean,
|
||||
focusAndShowKeyboard: Boolean = LocalConfiguration.current.screenHeightDp.dp > 600.dp,
|
||||
shouldResetContactsList: Boolean,
|
||||
callbacks: RecipientPickerCallbacks,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
@@ -54,6 +73,7 @@ fun RecipientPicker(
|
||||
onFilterChanged = { filter ->
|
||||
searchQuery = filter
|
||||
},
|
||||
focusAndShowKeyboard = focusAndShowKeyboard,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
@@ -64,6 +84,8 @@ fun RecipientPicker(
|
||||
enableCreateNewGroup = enableCreateNewGroup,
|
||||
enableFindByUsername = enableFindByUsername,
|
||||
enableFindByPhoneNumber = enableFindByPhoneNumber,
|
||||
isRefreshing = isRefreshing,
|
||||
shouldResetContactsList = shouldResetContactsList,
|
||||
callbacks = callbacks,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -81,6 +103,7 @@ fun RecipientPicker(
|
||||
private fun RecipientSearchField(
|
||||
onFilterChanged: (String) -> Unit,
|
||||
@StringRes hintText: Int? = null,
|
||||
focusAndShowKeyboard: Boolean = false,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
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) {
|
||||
wrappedView.setOnFilterChangedListener { filter -> onFilterChanged(filter) }
|
||||
onDispose {
|
||||
@@ -109,6 +143,8 @@ private fun RecipientSearchResultsList(
|
||||
enableCreateNewGroup: Boolean,
|
||||
enableFindByUsername: Boolean,
|
||||
enableFindByPhoneNumber: Boolean,
|
||||
isRefreshing: Boolean,
|
||||
shouldResetContactsList: Boolean,
|
||||
callbacks: RecipientPickerCallbacks,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
@@ -120,6 +156,7 @@ private fun RecipientSearchResultsList(
|
||||
|
||||
val fragmentState = rememberFragmentState()
|
||||
var currentFragment by remember { mutableStateOf<ContactSelectionListFragment?>(null) }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
Fragments.Fragment<ContactSelectionListFragment>(
|
||||
arguments = fragmentArgs,
|
||||
@@ -131,7 +168,8 @@ private fun RecipientSearchResultsList(
|
||||
callbacks = callbacks,
|
||||
enableCreateNewGroup = enableCreateNewGroup,
|
||||
enableFindByUsername = enableFindByUsername,
|
||||
enableFindByPhoneNumber = enableFindByPhoneNumber
|
||||
enableFindByPhoneNumber = enableFindByPhoneNumber,
|
||||
coroutineScope = coroutineScope
|
||||
)
|
||||
},
|
||||
modifier = modifier
|
||||
@@ -148,13 +186,30 @@ private fun RecipientSearchResultsList(
|
||||
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(
|
||||
callbacks: RecipientPickerCallbacks,
|
||||
enableCreateNewGroup: Boolean,
|
||||
enableFindByUsername: Boolean,
|
||||
enableFindByPhoneNumber: Boolean
|
||||
enableFindByPhoneNumber: Boolean,
|
||||
coroutineScope: CoroutineScope
|
||||
) {
|
||||
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 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
|
||||
@@ -203,6 +335,8 @@ private fun RecipientPickerPreview() {
|
||||
enableCreateNewGroup = true,
|
||||
enableFindByUsername = true,
|
||||
enableFindByPhoneNumber = true,
|
||||
isRefreshing = false,
|
||||
shouldResetContactsList = false,
|
||||
callbacks = RecipientPickerCallbacks.Empty
|
||||
)
|
||||
}
|
||||
@@ -220,7 +354,13 @@ interface RecipientPickerCallbacks {
|
||||
fun shouldAllowSelection(id: RecipientId): Boolean
|
||||
fun onRecipientSelected(id: RecipientId?, phone: PhoneNumber?)
|
||||
fun onMessage(id: RecipientId)
|
||||
fun onVoiceCall(recipient: Recipient)
|
||||
fun onVideoCall(recipient: Recipient)
|
||||
fun onRemove(recipient: Recipient)
|
||||
fun onBlock(recipient: Recipient)
|
||||
fun onInviteToSignal()
|
||||
fun onRefresh()
|
||||
fun onContactsListReset()
|
||||
|
||||
object Empty : RecipientPickerCallbacks {
|
||||
override fun onCreateNewGroup() = Unit
|
||||
@@ -229,6 +369,12 @@ interface RecipientPickerCallbacks {
|
||||
override fun shouldAllowSelection(id: RecipientId): Boolean = true
|
||||
override fun onRecipientSelected(id: RecipientId?, phone: PhoneNumber?) = 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 onRefresh() = Unit
|
||||
override fun onContactsListReset() = Unit
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5918,8 +5918,8 @@
|
||||
<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 -->
|
||||
<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 -->
|
||||
<string name="NewConversationActivity__">%1$s is not a Signal user</string>
|
||||
<!-- Accessibility label for opening the screen specific dropdown menu. -->
|
||||
<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) -->
|
||||
<plurals name="CreateGroupActivity_not_signal_users">
|
||||
<item quantity="one">%1$s is not a Signal user</item>
|
||||
|
||||
Reference in New Issue
Block a user