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) {
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);

View File

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

View File

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

View File

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

View File

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