diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt index 6d90098c26..636131224f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt @@ -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), diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/dsl.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/dsl.kt index a70c0b69a9..d09759cb39 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/dsl.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/dsl.kt @@ -377,4 +377,8 @@ class SectionHeaderPreference( override val title: DSLSettingsText, override val iconEnd: DSLSettingsIcon? = null, val onClick: (() -> Unit)? = null -) : PreferenceModel() +) : PreferenceModel() { + override fun areContentsTheSame(newItem: SectionHeaderPreference): Boolean { + return super.areContentsTheSame(newItem) && onClick == null && newItem.onClick == null + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/MemberSearchFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/MemberSearchFragment.kt index 8c59e8a751..676d632c9f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/MemberSearchFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/MemberSearchFragment.kt @@ -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()?.getArbitraryRepository(), + arbitraryRepository = findListener()?.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): Set { val recipientId = contactSearchKeys.filterIsInstance().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()?.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 { + override fun areItemsTheSame(newItem: DividerModel) = true + override fun areContentsTheSame(newItem: DividerModel) = true + } + + class AddMembersModel : MappingModel { + override fun areItemsTheSame(newItem: AddMembersModel) = true + override fun areContentsTheSame(newItem: AddMembersModel) = true + } + + class InviteViaModel : MappingModel { + 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 { + 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 = rememberMappingEntryProvider { + entry { Dividers.Default() } + + entry { + 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 { + 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) ) diff --git a/app/src/main/res/navigation/conversation_settings.xml b/app/src/main/res/navigation/conversation_settings.xml index 80fb688322..e82c281f96 100644 --- a/app/src/main/res/navigation/conversation_settings.xml +++ b/app/src/main/res/navigation/conversation_settings.xml @@ -142,6 +142,14 @@ android:name="group_id" app:argType="org.thoughtcrime.securesms.groups.GroupId" app:nullable="false" /> + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e7c3c681da..c364edf62a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -9898,6 +9898,8 @@ Admins Contacts + + Invite via group link Can\'t restore backup