Add invite and add member to group search.

This commit is contained in:
Michelle Tang
2026-07-06 11:56:55 -04:00
committed by Alex Hart
parent 2373f567d8
commit 0b92bc41fe
5 changed files with 160 additions and 9 deletions
@@ -84,6 +84,7 @@ import org.thoughtcrime.securesms.groups.ui.GroupErrors
import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry
import org.thoughtcrime.securesms.groups.ui.LeaveGroupDialog
import org.thoughtcrime.securesms.groups.ui.MemberSearchFragment
import org.thoughtcrime.securesms.groups.ui.addmembers.AddMembersActivity
import org.thoughtcrime.securesms.groups.ui.addtogroup.AddToGroupsActivity
import org.thoughtcrime.securesms.groups.ui.invitesandrequests.ManagePendingAndRequestingMembersActivity
@@ -221,6 +222,10 @@ class ConversationSettingsFragment :
navController.safeNavigate(ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToMemberLabelFragment(groupId))
}
parentFragmentManager.setFragmentResultListener(MemberSearchFragment.RESULT_ADD_MEMBERS, viewLifecycleOwner) { _, _ ->
viewModel.onAddToGroup()
}
recyclerView?.addOnScrollListener(ConversationSettingsOnUserScrolledAnimationHelper(toolbarAvatarContainer, toolbarTitle, toolbarBackground))
}
@@ -835,6 +840,7 @@ class ConversationSettingsFragment :
state.withGroupSettingsState { groupState ->
val memberCount = groupState.allMembers.size
val canAdd = groupState.canAddToGroup && !groupState.isTerminated && !state.isDeprecatedOrUnregistered
if (groupState.canAddToGroup || memberCount > 0) {
dividerPref()
@@ -850,7 +856,7 @@ class ConversationSettingsFragment :
title = DSLSettingsText.from(memberHeaderText),
iconEnd = DSLSettingsIcon.from(CoreUiR.drawable.symbol_search_24),
onClick = {
val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToMemberSearchFragment(groupState.groupId)
val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToMemberSearchFragment(groupState.groupId, canAdd, groupState.groupLinkEnabled)
navController.safeNavigate(action)
}
)
@@ -859,7 +865,7 @@ class ConversationSettingsFragment :
}
}
if (groupState.canAddToGroup && !groupState.isTerminated && !state.isDeprecatedOrUnregistered) {
if (canAdd) {
customPref(
LargeIconClickPreference.Model(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__add_members),
@@ -377,4 +377,8 @@ class SectionHeaderPreference(
override val title: DSLSettingsText,
override val iconEnd: DSLSettingsIcon? = null,
val onClick: (() -> Unit)? = null
) : PreferenceModel<SectionHeaderPreference>()
) : PreferenceModel<SectionHeaderPreference>() {
override fun areContentsTheSame(newItem: SectionHeaderPreference): Boolean {
return super.areContentsTheSame(newItem) && onClick == null && newItem.onClick == null
}
}
@@ -3,10 +3,13 @@ package org.thoughtcrime.securesms.groups.ui
import android.os.Bundle
import android.view.View
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -32,20 +35,26 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.setFragmentResult
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.fragment.findNavController
import org.signal.core.ui.compose.ComposeFragment
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Dividers
import org.signal.core.ui.compose.LocalFragmentManager
import org.signal.core.ui.compose.Rows
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.horizontalGutters
import org.signal.core.util.logging.Log
import org.signal.core.util.requireParcelableCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contacts.paged.ArbitraryRepository
import org.thoughtcrime.securesms.contacts.paged.ContactSearch
import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter
import org.thoughtcrime.securesms.contacts.paged.ContactSearchCallbacks
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
import org.thoughtcrime.securesms.contacts.paged.ContactSearchData
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.contacts.paged.ContactSearchPagedDataSourceRepository
import org.thoughtcrime.securesms.contacts.paged.ContactSearchRepository
@@ -55,8 +64,15 @@ import org.thoughtcrime.securesms.conversation.RecipientSearchBar
import org.thoughtcrime.securesms.conversation.mutiselect.forward.SearchConfigurationProvider
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.SelectionLimits
import org.thoughtcrime.securesms.groups.ui.MemberSearchFragment.AddMembersModel
import org.thoughtcrime.securesms.groups.ui.MemberSearchFragment.DividerModel
import org.thoughtcrime.securesms.groups.ui.MemberSearchFragment.InviteViaModel
import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment
import org.thoughtcrime.securesms.recipients.ui.sharablegrouplink.GroupLinkBottomSheetDialogFragment
import org.thoughtcrime.securesms.search.SearchRepository
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.compose.MappingEntryProvider
import org.thoughtcrime.securesms.util.adapter.mapping.compose.rememberMappingEntryProvider
import org.thoughtcrime.securesms.util.fragments.findListener
/**
@@ -68,11 +84,17 @@ class MemberSearchFragment : ComposeFragment(), RecipientBottomSheetDialogFragme
private val TAG = Log.tag(MemberSearchFragment::class.java)
private const val ARG_GROUP_ID = "group_id"
private const val ARG_CAN_ADD = "can_add"
private const val ARG_HAS_GROUP_LINK = "has_group_link"
fun newInstance(groupId: GroupId.V2): MemberSearchFragment {
const val RESULT_ADD_MEMBERS = "result_add"
fun newInstance(groupId: GroupId.V2, canAdd: Boolean, hasGroupLink: Boolean): MemberSearchFragment {
return MemberSearchFragment().apply {
arguments = Bundle().apply {
putParcelable(ARG_GROUP_ID, groupId)
putBoolean(ARG_CAN_ADD, canAdd)
putBoolean(ARG_HAS_GROUP_LINK, hasGroupLink)
}
}
}
@@ -88,7 +110,7 @@ class MemberSearchFragment : ComposeFragment(), RecipientBottomSheetDialogFragme
isMultiSelect = false,
repository = ContactSearchRepository(),
performSafetyNumberChecks = false,
arbitraryRepository = findListener<SearchConfigurationProvider>()?.getArbitraryRepository(),
arbitraryRepository = findListener<SearchConfigurationProvider>()?.getArbitraryRepository() ?: MemberArbitraryRepository(),
searchRepository = SearchRepository(requireContext().getString(R.string.Recipient_you)),
contactSearchPagedDataSourceRepository = ContactSearchPagedDataSourceRepository(requireContext(), requireContext().getString(R.string.Recipient_you))
)
@@ -105,16 +127,22 @@ class MemberSearchFragment : ComposeFragment(), RecipientBottomSheetDialogFragme
@Composable
override fun FragmentContent() {
val memberFilter by memberSearchViewModel.memberFilter.collectAsStateWithLifecycle()
val canAdd = requireArguments().getBoolean(ARG_CAN_ADD)
val hasGroupLink = requireArguments().getBoolean(ARG_HAS_GROUP_LINK)
CompositionLocalProvider(LocalFragmentManager provides childFragmentManager) {
MemberSearchScreen(
contactViewModel = contactViewModel,
memberFilter = memberFilter,
onFilterSelected = { memberSearchViewModel.setFilter(it) },
mapStateToConfiguration = { state -> getConfiguration(state, memberFilter) },
mapStateToConfiguration = { state -> getConfiguration(state, memberFilter, canAdd, hasGroupLink) },
contactSearchCallbacks = remember {
SearchCallbacks(
fragmentManager = childFragmentManager,
groupId = groupId
groupId = groupId,
onAddMembers = {
setFragmentResult(RESULT_ADD_MEMBERS, Bundle.EMPTY)
findNavController().popBackStack()
}
)
}
)
@@ -123,7 +151,8 @@ class MemberSearchFragment : ComposeFragment(), RecipientBottomSheetDialogFragme
class SearchCallbacks(
private val fragmentManager: FragmentManager,
private val groupId: GroupId.V2
private val groupId: GroupId.V2,
private val onAddMembers: () -> Unit
) : ContactSearchCallbacks.Simple() {
override fun onBeforeContactsSelected(view: View?, contactSearchKeys: Set<ContactSearchKey>): Set<ContactSearchKey> {
val recipientId = contactSearchKeys.filterIsInstance<ContactSearchKey.RecipientSearchKey>().firstOrNull()?.recipientId
@@ -132,9 +161,17 @@ class MemberSearchFragment : ComposeFragment(), RecipientBottomSheetDialogFragme
}
return emptySet()
}
fun onAdd() {
onAddMembers()
}
fun onInvite() {
GroupLinkBottomSheetDialogFragment.show(fragmentManager, groupId)
}
}
private fun getConfiguration(contactSearchState: ContactSearchState, memberFilter: MemberSearchViewModel.MemberFilter): ContactSearchConfiguration {
private fun getConfiguration(contactSearchState: ContactSearchState, memberFilter: MemberSearchViewModel.MemberFilter, canAdd: Boolean, hasGroupLink: Boolean): ContactSearchConfiguration {
return findListener<SearchConfigurationProvider>()?.getSearchConfiguration(childFragmentManager, contactSearchState) ?: ContactSearchConfiguration.build {
query = contactSearchState.query
@@ -153,11 +190,65 @@ class MemberSearchFragment : ComposeFragment(), RecipientBottomSheetDialogFragme
)
)
if (canAdd || hasGroupLink) {
val set = if (canAdd && hasGroupLink) {
setOf(MemberArbitraryRepository.TYPE_DIVIDER, MemberArbitraryRepository.TYPE_ADD_MEMBERS, MemberArbitraryRepository.TYPE_INVITE_VIA)
} else if (canAdd) {
setOf(MemberArbitraryRepository.TYPE_DIVIDER, MemberArbitraryRepository.TYPE_ADD_MEMBERS)
} else {
setOf(MemberArbitraryRepository.TYPE_DIVIDER, MemberArbitraryRepository.TYPE_INVITE_VIA)
}
addSection(ContactSearchConfiguration.Section.Arbitrary(set))
}
withEmptyState {
addSection(ContactSearchConfiguration.Section.Empty)
addSection(
ContactSearchConfiguration.Section.Arbitrary(
setOf(MemberArbitraryRepository.TYPE_DIVIDER, MemberArbitraryRepository.TYPE_ADD_MEMBERS, MemberArbitraryRepository.TYPE_INVITE_VIA)
)
)
}
}
}
class DividerModel : MappingModel<DividerModel> {
override fun areItemsTheSame(newItem: DividerModel) = true
override fun areContentsTheSame(newItem: DividerModel) = true
}
class AddMembersModel : MappingModel<AddMembersModel> {
override fun areItemsTheSame(newItem: AddMembersModel) = true
override fun areContentsTheSame(newItem: AddMembersModel) = true
}
class InviteViaModel : MappingModel<InviteViaModel> {
override fun areItemsTheSame(newItem: InviteViaModel) = true
override fun areContentsTheSame(newItem: InviteViaModel) = true
}
class MemberArbitraryRepository : ArbitraryRepository {
override fun getSize(section: ContactSearchConfiguration.Section.Arbitrary, query: String?) = section.types.size
override fun getData(section: ContactSearchConfiguration.Section.Arbitrary, query: String?, startIndex: Int, endIndex: Int, totalSearchSize: Int): List<ContactSearchData.Arbitrary> {
return section.types.toList().subList(startIndex, endIndex).map { ContactSearchData.Arbitrary(it) }
}
override fun getMappingModel(arbitrary: ContactSearchData.Arbitrary): MappingModel<*> {
return when (arbitrary.type) {
TYPE_DIVIDER -> DividerModel()
TYPE_ADD_MEMBERS -> AddMembersModel()
TYPE_INVITE_VIA -> InviteViaModel()
else -> throw IllegalArgumentException("Unknown type: ${arbitrary.type}")
}
}
companion object {
const val TYPE_DIVIDER = "divider"
const val TYPE_ADD_MEMBERS = "add-members"
const val TYPE_INVITE_VIA = "invite-via"
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -229,6 +320,45 @@ private fun MemberSearchContent(
contactSearchCallbacks: MemberSearchFragment.SearchCallbacks
) {
val focusRequester = remember { FocusRequester() }
val additionalEntries: MappingEntryProvider<Any> = rememberMappingEntryProvider {
entry<DividerModel> { Dividers.Default() }
entry<AddMembersModel> {
Rows.TextRow(
text = { Text(text = stringResource(R.string.AddMembersActivity__add_members)) },
icon = {
Icon(
imageVector = ImageVector.vectorResource(org.signal.core.ui.R.drawable.symbol_plus_24),
tint = MaterialTheme.colorScheme.onSurface,
contentDescription = null,
modifier = Modifier
.size(40.dp)
.background(color = MaterialTheme.colorScheme.surfaceVariant, shape = CircleShape)
.padding(8.dp)
)
},
onClick = contactSearchCallbacks::onAdd
)
}
entry<InviteViaModel> {
Rows.TextRow(
text = { Text(text = stringResource(R.string.MemberSearchFragment__invite_via)) },
icon = {
Icon(
imageVector = ImageVector.vectorResource(org.signal.core.ui.R.drawable.symbol_link_24),
tint = MaterialTheme.colorScheme.onSurface,
contentDescription = null,
modifier = Modifier
.size(40.dp)
.background(color = MaterialTheme.colorScheme.surfaceVariant, shape = CircleShape)
.padding(8.dp)
)
},
onClick = contactSearchCallbacks::onInvite
)
}
}
LaunchedEffect(Unit) {
focusRequester.requestFocus()
@@ -265,6 +395,7 @@ private fun MemberSearchContent(
displaySecondaryInformation = ContactSearchAdapter.DisplaySecondaryInformation.NEVER
)
},
additionalEntries = additionalEntries,
callbacks = contactSearchCallbacks,
modifier = Modifier.weight(1f)
)
@@ -142,6 +142,14 @@
android:name="group_id"
app:argType="org.thoughtcrime.securesms.groups.GroupId"
app:nullable="false" />
<argument
android:name="can_add"
app:argType="boolean" />
<argument
android:name="has_group_link"
app:argType="boolean" />
</action>
</fragment>
+2
View File
@@ -9898,6 +9898,8 @@
<string name="MemberSearchFragment__filter_admins">Admins</string>
<!-- Dialog option for your contacts -->
<string name="MemberSearchFragment__filter_contacts">Contacts</string>
<!-- Button to invite members via a group link -->
<string name="MemberSearchFragment__invite_via">Invite via group link</string>
<!-- Title of the sheet shown when a local backup restore could not be completed -->
<string name="CouldNotCompleteBackupRestoreSheet__title">Can\'t restore backup</string>