From a849af6880a5df2923c880a6c17165aa7eb2af5c Mon Sep 17 00:00:00 2001 From: Michelle Tang Date: Thu, 2 Jul 2026 15:16:18 -0400 Subject: [PATCH] Add more features to group member search. --- .../ContactSelectionListFragment.java | 2 +- .../securesms/contacts/ContactRepository.java | 12 +- .../paged/ContactSearchConfiguration.kt | 13 +- .../contacts/paged/ContactSearchData.kt | 4 +- .../contacts/paged/ContactSearchModels.kt | 23 +++- .../paged/ContactSearchPagedDataSource.kt | 23 +++- .../ContactSearchPagedDataSourceRepository.kt | 19 ++- .../contacts/paged/ContactSearchViewModel.kt | 5 + .../ConversationListFragment.java | 2 +- .../securesms/database/RecipientTable.kt | 17 ++- .../groups/ui/MemberSearchFragment.kt | 130 +++++++++++++----- .../groups/ui/MemberSearchViewModel.kt | 20 +++ .../main/res/layout/contact_search_item.xml | 10 ++ app/src/main/res/values/arrays.xml | 6 + app/src/main/res/values/strings.xml | 8 ++ 15 files changed, 232 insertions(+), 62 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/ui/MemberSearchViewModel.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java index 45c50572da..aed85b00ff 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java @@ -280,7 +280,7 @@ public final class ContactSelectionListFragment extends LoggingFragment { false, new ContactSelectionListAdapter.ArbitraryRepository(), new SearchRepository(requireContext().getString(R.string.note_to_self)), - new ContactSearchPagedDataSourceRepository(requireContext()), + new ContactSearchPagedDataSourceRepository(requireContext(), requireContext().getString(R.string.note_to_self)), fixedContacts, false ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactRepository.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactRepository.java index 73d708fd52..9b260aaa73 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactRepository.java @@ -33,7 +33,7 @@ import kotlin.Pair; public class ContactRepository { private final RecipientTable recipientTable; - private final String noteToSelfTitle; + private final String selfTitle; public static final String ID_COLUMN = "id"; public static final String NAME_COLUMN = "name"; @@ -101,14 +101,14 @@ public class ContactRepository { })); }}; - public ContactRepository(@NonNull String noteToSelfTitle) { - this.noteToSelfTitle = noteToSelfTitle; - this.recipientTable = SignalDatabase.recipients(); + public ContactRepository(@NonNull String selfTitle) { + this.selfTitle = selfTitle; + this.recipientTable = SignalDatabase.recipients(); } @WorkerThread public @NonNull Cursor querySignalContacts(@NonNull String query) { - return querySignalContacts(new RecipientTable.ContactSearchQuery(query, new RecipientTable.IncludeSelfMode.IncludeWithRemap(noteToSelfTitle), ContactSearchSortOrder.NATURAL)); + return querySignalContacts(new RecipientTable.ContactSearchQuery(query, new RecipientTable.IncludeSelfMode.IncludeWithRemap(selfTitle), ContactSearchSortOrder.NATURAL)); } @WorkerThread @@ -123,7 +123,7 @@ public class ContactRepository { public @NonNull Cursor queryGroupMemberContacts(@NonNull String query, @Nullable GroupId groupId) { Cursor cursor; if (groupId != null) { - cursor = recipientTable.queryGroupMemberContactsForGroup(groupId, query); + cursor = recipientTable.queryGroupMemberContactsForGroup(groupId, query, selfTitle); } else if (TextUtils.isEmpty(query)) { cursor = recipientTable.getGroupMemberContacts(); } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchConfiguration.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchConfiguration.kt index 1c5d3a270b..156077781d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchConfiguration.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchConfiguration.kt @@ -135,7 +135,9 @@ class ContactSearchConfiguration private constructor( override val expandConfig: ExpandConfig? = null, val includeLetterHeaders: Boolean = false, val showGroupsInCommon: Boolean = true, - val groupId: GroupId? = null + val groupId: GroupId? = null, + val showSelfAsYou: Boolean = false, + val roleFilter: MemberRole = MemberRole.ALL ) : Section(SectionKey.GROUP_MEMBERS) /** @@ -299,6 +301,15 @@ class ContactSearchConfiguration private constructor( val maxCountWhenNotExpanded: (ActiveContactCount) -> Int = { 2 } ) + /** + * Role-based filter for [Section.GroupMembers]. + */ + enum class MemberRole { + ALL, + ADMINS, + CONTACTS + } + /** * Network transport type for individual recipients. */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchData.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchData.kt index 5d94ba338a..17ff77e205 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchData.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchData.kt @@ -33,7 +33,9 @@ sealed class ContactSearchData(val contactSearchKey: ContactSearchKey) { val recipient: Recipient, val shortSummary: Boolean = false, val headerLetter: String? = null, - val groupsInCommon: GroupsInCommonSummary = GroupsInCommonSummary(listOf()) + val groupsInCommon: GroupsInCommonSummary = GroupsInCommonSummary(listOf()), + val showSelfAsYou: Boolean = false, + val showAdminLabel: Boolean = false ) : ContactSearchData(ContactSearchKey.RecipientSearchKey(recipient.id, false)) /** diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchModels.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchModels.kt index 009e350691..0517e3c3a1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchModels.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchModels.kt @@ -19,7 +19,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.getValue -import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -467,7 +466,9 @@ object ContactSearchModels { ) : MappingModel, FastScrollCharacterProvider { override fun getFastScrollCharacter(context: Context): CharSequence { - val name = if (knownRecipient.recipient.isSelf) { + val name = if (knownRecipient.recipient.isSelf && knownRecipient.showSelfAsYou) { + context.getString(R.string.Recipient_you) + } else if (knownRecipient.recipient.isSelf) { context.getString(R.string.note_to_self) } else { knownRecipient.recipient.getDisplayName(context) @@ -577,6 +578,16 @@ object ContactSearchModels { override fun isSelected(model: RecipientModel): Boolean = model.isSelected override fun getData(model: RecipientModel): ContactSearchData.KnownRecipient = model.knownRecipient override fun getRecipient(model: RecipientModel): Recipient = model.knownRecipient.recipient + override fun showSelfAsYou(model: RecipientModel): Boolean = model.knownRecipient.showSelfAsYou + + override fun bindAvatar(model: RecipientModel) { + if (model.knownRecipient.showSelfAsYou) { + avatar.setAvatarUsingProfile(getRecipient(model)) + } else { + super.bindAvatar(model) + } + } + override fun bindNumberField(model: RecipientModel) { val recipient = getRecipient(model) if (model.knownRecipient.sectionKey == ContactSearchConfiguration.SectionKey.GROUP_MEMBERS && displayOptions.displaySecondaryInformation != ContactSearchAdapter.DisplaySecondaryInformation.NEVER) { @@ -609,6 +620,10 @@ object ContactSearchModels { checkbox.isEnabled = !fixedContacts.contains(model.knownRecipient.contactSearchKey) } + override fun bindLabelField(model: RecipientModel) { + adminLabel.visible = model.knownRecipient.showAdminLabel + } + override fun isEnabled(model: RecipientModel): Boolean { return !fixedContacts.contains(model.knownRecipient.contactSearchKey) } @@ -638,6 +653,7 @@ object ContactSearchModels { protected val name: FromTextView = itemView.findViewById(R.id.name) protected val number: TextView = itemView.findViewById(R.id.number) protected val label: TextView = itemView.findViewById(R.id.label) + protected val adminLabel: TextView = itemView.findViewById(R.id.admin_label) private val startAudio: View = itemView.findViewById(R.id.start_audio) private val startVideo: View = itemView.findViewById(R.id.start_video) @@ -666,7 +682,7 @@ object ContactSearchModels { } else { null } - name.setText(recipient, suffix) + name.setText(recipient, recipient.getDisplayName(context), suffix, true, showSelfAsYou(model)) badge.setBadgeFromRecipient(getRecipient(model)) @@ -682,6 +698,7 @@ object ContactSearchModels { } protected open fun isEnabled(model: T): Boolean = true + protected open fun showSelfAsYou(model: T): Boolean = false protected open fun bindAvatar(model: T) { avatar.setAvatar(getRecipient(model)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt index 720a54d70f..b45732a467 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt @@ -136,7 +136,7 @@ class ContactSearchPagedDataSource( is ContactSearchConfiguration.Section.Recents -> getRecentsSearchIterator(section, query).getCollectionSizeAndClose(section, query, null) is ContactSearchConfiguration.Section.Stories -> getStoriesSearchIterator(query).getCollectionSizeAndClose(section, query, null) is ContactSearchConfiguration.Section.Arbitrary -> arbitraryRepository?.getSize(section, query) ?: error("Invalid arbitrary section.") - is ContactSearchConfiguration.Section.GroupMembers -> getGroupMembersSearchIterator(section, query).getCollectionSizeAndClose(section, query, null) + is ContactSearchConfiguration.Section.GroupMembers -> getGroupMembersSearchIterator(section, query).getCollectionSizeAndClose(section, query, filterByRole(section)) is ContactSearchConfiguration.Section.Chats -> getThreadData(query, section.isUnreadOnly).getCollectionSizeAndClose(section, query, null) is ContactSearchConfiguration.Section.Messages -> getMessageData(query).getCollectionSizeAndClose(section, query, null) is ContactSearchConfiguration.Section.GroupsWithMembers -> getGroupsWithMembersIterator(query).getCollectionSizeAndClose(section, query, null) @@ -444,12 +444,29 @@ class ContactSearchPagedDataSource( } } + private fun filterByRole(section: ContactSearchConfiguration.Section.GroupMembers): ((Cursor) -> Boolean)? { + if (section.roleFilter == ContactSearchConfiguration.MemberRole.ALL || section.groupId == null) { + return null + } + + val groupRecord = contactSearchPagedDataSourceRepository.getGroupRecord(section.groupId) ?: return null + return { cursor -> + val recipient = contactSearchPagedDataSourceRepository.getRecipientFromSearchCursor(cursor) + when (section.roleFilter) { + ContactSearchConfiguration.MemberRole.ALL -> true + ContactSearchConfiguration.MemberRole.ADMINS -> groupRecord.isAdmin(recipient) + ContactSearchConfiguration.MemberRole.CONTACTS -> recipient.isSystemContact + } + } + } + @WorkerThread private fun getGroupMembersContactData(section: ContactSearchConfiguration.Section.GroupMembers, query: String?, startIndex: Int, endIndex: Int): List { + val groupRecord = section.groupId?.let { contactSearchPagedDataSourceRepository.getGroupRecord(it) } return getGroupMembersSearchIterator(section, query).use { records -> readContactData( records = records, - recordsPredicate = null, + recordsPredicate = filterByRole(section), section = section, startIndex = startIndex, endIndex = endIndex, @@ -457,7 +474,7 @@ class ContactSearchPagedDataSource( val recipient = contactSearchPagedDataSourceRepository.getRecipientFromSearchCursor(it) val groupsInCommon = if (section.showGroupsInCommon) contactSearchPagedDataSourceRepository.getGroupsInCommon(recipient) else GroupsInCommonSummary(listOf()) val headerLetter = if (section.includeLetterHeaders) getHeaderLetterForCurrentRow(it) else null - ContactSearchData.KnownRecipient(section.sectionKey, recipient, groupsInCommon = groupsInCommon, headerLetter = headerLetter) + ContactSearchData.KnownRecipient(section.sectionKey, recipient, groupsInCommon = groupsInCommon, headerLetter = headerLetter, showSelfAsYou = section.showSelfAsYou, showAdminLabel = groupRecord?.isAdmin(recipient) == true) } ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSourceRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSourceRepository.kt index d81bfd7940..f12cc3ff37 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSourceRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSourceRepository.kt @@ -6,6 +6,7 @@ import androidx.annotation.WorkerThread import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import org.signal.core.util.CursorUtil +import org.signal.core.util.LRUCache import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.contacts.ContactRepository import org.thoughtcrime.securesms.contacts.paged.collections.ContactSearchIterator @@ -16,6 +17,7 @@ import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.ThreadTable import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode import org.thoughtcrime.securesms.database.model.GroupRecord +import org.thoughtcrime.securesms.groups.GroupId import org.thoughtcrime.securesms.groups.GroupsInCommonRepository import org.thoughtcrime.securesms.groups.GroupsInCommonSummary import org.thoughtcrime.securesms.keyvalue.SignalStore @@ -28,11 +30,13 @@ import org.thoughtcrime.securesms.recipients.RecipientId * having to deal with database access. */ open class ContactSearchPagedDataSourceRepository( - context: Context + context: Context, + selfTitle: String = context.getString(R.string.note_to_self) ) { - private val contactRepository = ContactRepository(context.getString(R.string.note_to_self)) + private val contactRepository = ContactRepository(selfTitle) private val context = context.applicationContext + private val groupRecordCache = LRUCache(100) open fun getLatestStorySends(activeStoryCutoffDuration: Long): List { return SignalStore.story @@ -142,4 +146,15 @@ open class ContactSearchPagedDataSourceRepository( val myStory = context.getString(R.string.Recipient_my_story) return myStory.contains(query, ignoreCase = true) } + + open fun getGroupRecord(groupId: GroupId): GroupRecord? { + if (!groupRecordCache.containsKey(groupId)) { + groupRecordCache[groupId] = SignalDatabase.groups.getGroup(groupId).orElse(null) + } + return groupRecordCache[groupId] + } + + open fun clearGroupRecordCache() { + groupRecordCache.clear() + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchViewModel.kt index e7c5597820..4d5d13fa8f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchViewModel.kt @@ -269,6 +269,11 @@ class ContactSearchViewModel( controller.value?.onDataInvalidated() } + fun refreshGroupData() { + contactSearchPagedDataSourceRepository.clearGroupRecordCache() + refresh() + } + data class ScrollRequest(val position: Int) class Factory( diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java index e8bf4f6cf6..9bf8dda77f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -340,7 +340,7 @@ public class ConversationListFragment extends MainFragment implements Conversati false, new ConversationListSearchAdapter.ChatFilterRepository(), new SearchRepository(requireContext().getString(R.string.note_to_self)), - new ContactSearchPagedDataSourceRepository(requireContext()), + new ContactSearchPagedDataSourceRepository(requireContext(), requireContext().getString(R.string.note_to_self)), Collections.emptySet(), true )).get(ContactSearchViewModel.class); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt index 4bb34e9edf..8e24c30123 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt @@ -3546,7 +3546,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da val selfId = Recipient.self().id.toLong() arrayOf( ID, - """CASE WHEN ${TABLE_NAME}.$ID = $selfId THEN '${includeSelfMode.noteToSelfTitle}' ELSE $SYSTEM_JOINED_NAME END AS $SYSTEM_JOINED_NAME""", + """CASE WHEN ${TABLE_NAME}.$ID = $selfId THEN '${includeSelfMode.title}' ELSE $SYSTEM_JOINED_NAME END AS $SYSTEM_JOINED_NAME""", E164, EMAIL, SYSTEM_PHONE_LABEL, @@ -3556,9 +3556,9 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da ABOUT_EMOJI, EXTRAS, GROUPS_IN_COMMON, - """CASE WHEN ${TABLE_NAME}.$ID = $selfId THEN '${includeSelfMode.noteToSelfTitle}' ELSE COALESCE(NULLIF($PROFILE_JOINED_NAME, ''), NULLIF($PROFILE_GIVEN_NAME, '')) END AS $SEARCH_PROFILE_NAME""", + """CASE WHEN ${TABLE_NAME}.$ID = $selfId THEN '${includeSelfMode.title}' ELSE COALESCE(NULLIF($PROFILE_JOINED_NAME, ''), NULLIF($PROFILE_GIVEN_NAME, '')) END AS $SEARCH_PROFILE_NAME""", """ - CASE WHEN ${TABLE_NAME}.$ID = $selfId THEN '${includeSelfMode.noteToSelfTitle.lowercase()}' ELSE + CASE WHEN ${TABLE_NAME}.$ID = $selfId THEN '${includeSelfMode.title.lowercase()}' ELSE LOWER( COALESCE( NULLIF($NICKNAME_JOINED_NAME, ''), @@ -3712,24 +3712,23 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da return readableDatabase.query(TABLE_NAME, searchProjection(IncludeSelfMode.Exclude), selection, args, null, null, orderBy) } - fun queryGroupMemberContactsForGroup(groupId: GroupId, inputQuery: String): Cursor? { + fun queryGroupMemberContactsForGroup(groupId: GroupId, inputQuery: String, selfTitle: String): Cursor? { val orderBy = orderByPreferringAlphaOverNumeric(SORT_NAME) + ", " + E164 val queryFilter = if (inputQuery.isNotEmpty()) "AND ($SORT_NAME GLOB ? OR $USERNAME GLOB ?)" else "" val selection = """ - $ID != ? AND $ID IN (SELECT ${GroupTable.MembershipTable.RECIPIENT_ID} FROM ${GroupTable.MembershipTable.TABLE_NAME} WHERE ${GroupTable.MembershipTable.GROUP_ID} = ?) $queryFilter """ val args = if (queryFilter.isBlank()) { - mutableListOf(Recipient.self().id.serialize(), groupId.toString()) + mutableListOf(groupId.toString()) } else { val query = SqlUtil.buildCaseInsensitiveGlobPattern(inputQuery) - mutableListOf(Recipient.self().id.serialize(), groupId.toString(), query, query) + mutableListOf(groupId.toString(), query, query) } - return readableDatabase.query(TABLE_NAME, searchProjection(IncludeSelfMode.Exclude), selection, args.toTypedArray(), null, null, orderBy) + return readableDatabase.query(TABLE_NAME, searchProjection(IncludeSelfMode.IncludeWithRemap(selfTitle)), selection, args.toTypedArray(), null, null, orderBy) } fun queryAllContacts(inputQuery: String, includeSelfMode: IncludeSelfMode): Cursor? { @@ -4785,7 +4784,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da data object Exclude : IncludeSelfMode data object IncludeWithoutRemap : IncludeSelfMode - data class IncludeWithRemap(val noteToSelfTitle: String) : IncludeSelfMode + data class IncludeWithRemap(val title: String) : IncludeSelfMode } @VisibleForTesting 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 9ce5534b41..8c59e8a751 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 @@ -4,12 +4,14 @@ import android.os.Bundle import android.view.View import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar @@ -17,16 +19,23 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringArrayResource 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.viewModels import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.signal.core.ui.compose.ComposeFragment +import org.signal.core.ui.compose.Dialogs import org.signal.core.ui.compose.LocalFragmentManager import org.signal.core.ui.compose.SignalIcons import org.signal.core.ui.compose.horizontalGutters @@ -51,9 +60,9 @@ import org.thoughtcrime.securesms.search.SearchRepository import org.thoughtcrime.securesms.util.fragments.findListener /** - * Fragment that shows all members in a group (excluding self) + * Fragment that shows all members in a group (including self) */ -class MemberSearchFragment : ComposeFragment() { +class MemberSearchFragment : ComposeFragment(), RecipientBottomSheetDialogFragment.Callback { companion object { @@ -81,16 +90,27 @@ class MemberSearchFragment : ComposeFragment() { performSafetyNumberChecks = false, arbitraryRepository = findListener()?.getArbitraryRepository(), searchRepository = SearchRepository(requireContext().getString(R.string.Recipient_you)), - contactSearchPagedDataSourceRepository = ContactSearchPagedDataSourceRepository(requireContext()) + contactSearchPagedDataSourceRepository = ContactSearchPagedDataSourceRepository(requireContext(), requireContext().getString(R.string.Recipient_you)) ) } + private val memberSearchViewModel: MemberSearchViewModel by viewModels() + + override fun onRecipientBottomSheetDismissed() { + contactViewModel.refreshGroupData() + } + + override fun onMessageClicked() = Unit + @Composable override fun FragmentContent() { + val memberFilter by memberSearchViewModel.memberFilter.collectAsStateWithLifecycle() CompositionLocalProvider(LocalFragmentManager provides childFragmentManager) { MemberSearchScreen( contactViewModel = contactViewModel, - mapStateToConfiguration = this::getConfiguration, + memberFilter = memberFilter, + onFilterSelected = { memberSearchViewModel.setFilter(it) }, + mapStateToConfiguration = { state -> getConfiguration(state, memberFilter) }, contactSearchCallbacks = remember { SearchCallbacks( fragmentManager = childFragmentManager, @@ -114,7 +134,7 @@ class MemberSearchFragment : ComposeFragment() { } } - private fun getConfiguration(contactSearchState: ContactSearchState): ContactSearchConfiguration { + private fun getConfiguration(contactSearchState: ContactSearchState, memberFilter: MemberSearchViewModel.MemberFilter): ContactSearchConfiguration { return findListener()?.getSearchConfiguration(childFragmentManager, contactSearchState) ?: ContactSearchConfiguration.build { query = contactSearchState.query @@ -123,7 +143,13 @@ class MemberSearchFragment : ComposeFragment() { includeHeader = false, includeLetterHeaders = true, groupId = groupId, - showGroupsInCommon = false + showGroupsInCommon = false, + showSelfAsYou = true, + roleFilter = when (memberFilter) { + MemberSearchViewModel.MemberFilter.ALL -> ContactSearchConfiguration.MemberRole.ALL + MemberSearchViewModel.MemberFilter.ADMINS -> ContactSearchConfiguration.MemberRole.ADMINS + MemberSearchViewModel.MemberFilter.CONTACTS -> ContactSearchConfiguration.MemberRole.CONTACTS + } ) ) @@ -138,10 +164,27 @@ class MemberSearchFragment : ComposeFragment() { @Composable fun MemberSearchScreen( contactViewModel: ContactSearchViewModel, + memberFilter: MemberSearchViewModel.MemberFilter, + onFilterSelected: (MemberSearchViewModel.MemberFilter) -> Unit, mapStateToConfiguration: (ContactSearchState) -> ContactSearchConfiguration, contactSearchCallbacks: MemberSearchFragment.SearchCallbacks ) { val onBackPressedDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher + var showFilterDialog by remember { mutableStateOf(false) } + + if (showFilterDialog) { + val filters = MemberSearchViewModel.MemberFilter.entries + + Dialogs.RadioListDialog( + onDismissRequest = { showFilterDialog = false }, + title = stringResource(R.string.MemberSearchFragment__filter), + labels = stringArrayResource(R.array.filter_search_entries), + values = filters.map { it.name }.toTypedArray(), + selectedIndex = filters.indexOf(memberFilter), + onSelected = { index -> onFilterSelected(filters[index]) } + ) + } + Scaffold( topBar = { TopAppBar( @@ -153,12 +196,23 @@ fun MemberSearchScreen( contentDescription = stringResource(R.string.DSLSettingsToolbar__navigate_up) ) } + }, + actions = { + val filterActive = memberFilter != MemberSearchViewModel.MemberFilter.ALL + IconButton(onClick = { showFilterDialog = true }) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.symbol_filter_24), + tint = if (filterActive) MaterialTheme.colorScheme.primary else LocalContentColor.current, + contentDescription = stringResource(R.string.MemberSearchFragment__filter) + ) + } } ) } ) { MemberSearchContent( contactViewModel = contactViewModel, + memberFilter = memberFilter, mapStateToConfiguration = mapStateToConfiguration, contactSearchCallbacks = contactSearchCallbacks, modifier = Modifier.padding(it) @@ -169,6 +223,7 @@ fun MemberSearchScreen( @Composable private fun MemberSearchContent( contactViewModel: ContactSearchViewModel, + memberFilter: MemberSearchViewModel.MemberFilter, mapStateToConfiguration: (ContactSearchState) -> ContactSearchConfiguration, modifier: Modifier = Modifier, contactSearchCallbacks: MemberSearchFragment.SearchCallbacks @@ -179,34 +234,39 @@ private fun MemberSearchContent( focusRequester.requestFocus() } - Row(modifier = modifier) { - Column( - modifier = Modifier - .weight(1f) - ) { - val query by contactViewModel.query.collectAsStateWithLifecycle() - RecipientSearchBar( - hint = stringResource(R.string.MemberSearchFragment__search_members), - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 12.dp) - .focusRequester(focusRequester) - .horizontalGutters(), - query = query ?: "", - onQueryChange = { contactViewModel.setQuery(it) }, - onSearch = { contactViewModel.setQuery(it) } - ) + val currentMapStateToConfiguration by rememberUpdatedState(mapStateToConfiguration) + LaunchedEffect(memberFilter) { + contactViewModel.setConfiguration( + currentMapStateToConfiguration(contactViewModel.configurationState.value) + ) + } - ContactSearch( - viewModel = contactViewModel, - mapStateToConfiguration = mapStateToConfiguration, - displayOptions = remember { - ContactSearchAdapter.DisplayOptions( - displaySecondaryInformation = ContactSearchAdapter.DisplaySecondaryInformation.NEVER - ) - }, - callbacks = contactSearchCallbacks - ) - } + Column( + modifier = modifier.fillMaxSize() + ) { + val query by contactViewModel.query.collectAsStateWithLifecycle() + RecipientSearchBar( + hint = stringResource(R.string.MemberSearchFragment__search_members), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp) + .focusRequester(focusRequester) + .horizontalGutters(), + query = query ?: "", + onQueryChange = { contactViewModel.setQuery(it) }, + onSearch = { contactViewModel.setQuery(it) } + ) + + ContactSearch( + viewModel = contactViewModel, + mapStateToConfiguration = mapStateToConfiguration, + displayOptions = remember { + ContactSearchAdapter.DisplayOptions( + displaySecondaryInformation = ContactSearchAdapter.DisplaySecondaryInformation.NEVER + ) + }, + callbacks = contactSearchCallbacks, + modifier = Modifier.weight(1f) + ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/MemberSearchViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/MemberSearchViewModel.kt new file mode 100644 index 0000000000..a43a34345e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/MemberSearchViewModel.kt @@ -0,0 +1,20 @@ +package org.thoughtcrime.securesms.groups.ui + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class MemberSearchViewModel : ViewModel() { + private val _memberFilter = MutableStateFlow(MemberFilter.ALL) + val memberFilter: StateFlow = _memberFilter + + fun setFilter(filter: MemberFilter) { + _memberFilter.value = filter + } + + enum class MemberFilter { + ALL, + ADMINS, + CONTACTS + } +} diff --git a/app/src/main/res/layout/contact_search_item.xml b/app/src/main/res/layout/contact_search_item.xml index 907c3843eb..a191b3da6f 100644 --- a/app/src/main/res/layout/contact_search_item.xml +++ b/app/src/main/res/layout/contact_search_item.xml @@ -140,4 +140,14 @@ app:tint="@color/signal_colorOnSurface" tools:visibility="visible" /> + + diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 9279d90cf3..b414a67039 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -427,4 +427,10 @@ @string/ConversationFragment__30_days @string/ConversationFragment__forever + + + @string/MemberSearchFragment__filter_all_members + @string/MemberSearchFragment__filter_admins + @string/MemberSearchFragment__filter_contacts + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7c052a7be0..e7c3c681da 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -9890,6 +9890,14 @@ Search members + + Filter by + + All members + + Admins + + Contacts Can\'t restore backup