diff --git a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java index ac3cf57eb4..6d1ab0e43f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java @@ -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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationActivityV2.kt index 6cfa8f6362..3352319d67 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationActivityV2.kt @@ -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 = 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) + } + } + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationViewModel.kt index 1fc8665951..e8af997162 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationViewModel.kt @@ -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 = _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 + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/RecipientPicker.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/RecipientPicker.kt index ccf16f98c4..80c6990eeb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/RecipientPicker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/RecipientPicker.kt @@ -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(null) } + val coroutineScope = rememberCoroutineScope() Fragments.Fragment( 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, number: String?, chatType: Optional) = 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 } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8031391353..5c3346efeb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5918,8 +5918,8 @@ View contact %1$s is not a Signal user - - %1$s is not a Signal user + + Open menu %1$s is not a Signal user