From 636247f83ade2f789428c9a1d5512122b2b959b3 Mon Sep 17 00:00:00 2001 From: Michelle Tang Date: Wed, 8 Jul 2026 11:59:20 -0400 Subject: [PATCH] Update group membership search. --- .../securesms/contacts/ContactRepository.java | 3 ++- .../contacts/paged/ContactSearchData.kt | 3 ++- .../contacts/paged/ContactSearchModels.kt | 20 +++++++++++++-- .../paged/ContactSearchPagedDataSource.kt | 22 +++++++++------- .../securesms/database/RecipientTable.kt | 4 +-- .../groups/ui/MemberSearchFragment.kt | 25 ++++++++++++------- .../main/res/drawable/symbol_filter_20.xml | 15 +++++++++++ app/src/main/res/values/strings.xml | 6 +++++ 8 files changed, 74 insertions(+), 24 deletions(-) create mode 100644 app/src/main/res/drawable/symbol_filter_20.xml 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 9b260aaa73..d06ab1d210 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactRepository.java @@ -183,7 +183,8 @@ public class ContactRepository { @Override public String getString(int columnIndex) { - return String.valueOf(mappers.get(columnIndex).getSecond().get(wrapped)); + Object value = mappers.get(columnIndex).getSecond().get(wrapped); + return value != null ? value.toString() : ""; } @Override 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 17ff77e205..9e6c76b44f 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 @@ -35,7 +35,8 @@ sealed class ContactSearchData(val contactSearchKey: ContactSearchKey) { val headerLetter: String? = null, val groupsInCommon: GroupsInCommonSummary = GroupsInCommonSummary(listOf()), val showSelfAsYou: Boolean = false, - val showAdminLabel: Boolean = false + val showAdminLabel: Boolean = false, + val query: String? = null ) : 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 0517e3c3a1..54f920ebf3 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 @@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.contacts.paged import android.content.Context import android.text.SpannableStringBuilder +import android.text.style.ForegroundColorSpan import android.view.View import android.view.ViewGroup import android.widget.CheckBox @@ -54,6 +55,7 @@ import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode import org.thoughtcrime.securesms.database.model.StoryViewState import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.util.SearchUtil import org.thoughtcrime.securesms.util.SpanUtil import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter @@ -63,6 +65,7 @@ import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder import org.thoughtcrime.securesms.util.adapter.mapping.compose.MappingEntryProvider import org.thoughtcrime.securesms.util.adapter.mapping.compose.MappingEntryProviderBuilder import org.thoughtcrime.securesms.util.visible +import java.util.Locale import org.signal.core.ui.R as CoreUiR /** @@ -230,7 +233,7 @@ object ContactSearchModels { key = { "EmptyModel" } ) { model -> Text( - text = stringResource(R.string.SearchFragment_no_results, model.empty.query ?: ""), + text = if (model.empty.query.isNullOrEmpty()) stringResource(R.string.SearchFragment_no_results_empty) else stringResource(R.string.SearchFragment_no_results, model.empty.query), textAlign = TextAlign.Center, modifier = Modifier .fillMaxWidth() @@ -635,6 +638,10 @@ object ContactSearchModels { override fun bindLongPress(model: RecipientModel) { itemView.setOnLongClickListener { onLongClick.onLongClicked(itemView, model.knownRecipient) } } + + override fun getHighlightQuery(model: RecipientModel): String? { + return model.knownRecipient.query + } } /** @@ -656,6 +663,7 @@ object ContactSearchModels { 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) + private val searchStyleFactory = SearchUtil.StyleFactory { arrayOf(ForegroundColorSpan(ContextCompat.getColor(context, CoreUiR.color.signal_colorOnSurface)), SpanUtil.getBoldSpan()) } override fun bind(model: T) { if (isEnabled(model)) { @@ -682,7 +690,14 @@ object ContactSearchModels { } else { null } - name.setText(recipient, recipient.getDisplayName(context), suffix, true, showSelfAsYou(model)) + val query = getHighlightQuery(model) + val displayName: CharSequence = if (!query.isNullOrBlank()) { + SearchUtil.getHighlightedSpan(Locale.getDefault(), searchStyleFactory, recipient.getDisplayName(context), query, SearchUtil.MATCH_ALL) + } else { + recipient.getDisplayName(context) + } + + name.setText(recipient, displayName, suffix, true, showSelfAsYou(model)) badge.setBadgeFromRecipient(getRecipient(model)) @@ -699,6 +714,7 @@ object ContactSearchModels { protected open fun isEnabled(model: T): Boolean = true protected open fun showSelfAsYou(model: T): Boolean = false + protected open fun getHighlightQuery(model: T): String? = null 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 b45732a467..339778e2a5 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 @@ -243,22 +243,25 @@ class ContactSearchPagedDataSource( /** * Returns the letter header to display above the recipient at the cursor's current row, or null if * none should be shown. A header is shown only when this row begins a new letter group, determined by - * comparing its letter to the immediately preceding row in display order. Peeking that single adjacent + * comparing its letter to the first visible preceding row in display order (filtered by recordPredicate). Peeking that single * row means a letter group split across pages still yields exactly one header, anchored to the first * row of the group, without re-scanning the whole contact set. * * The cursor is restored to its original position before returning so iteration is unaffected. */ - private fun getHeaderLetterForCurrentRow(cursor: Cursor): String? { + private fun getHeaderLetterForCurrentRow(cursor: Cursor, recordPredicate: ((Cursor) -> Boolean)? = null): String? { val position = cursor.position val currentLetter = letterForCurrentRow(cursor) ?: return null - if (position <= 0) { - return currentLetter + var foundPrevious = false + while (cursor.moveToPrevious()) { + if (recordPredicate == null || recordPredicate.invoke(cursor)) { + foundPrevious = true + break + } } - cursor.moveToPosition(position - 1) - val previousLetter = letterForCurrentRow(cursor) + val previousLetter = if (foundPrevious) letterForCurrentRow(cursor) else null cursor.moveToPosition(position) return if (previousLetter != currentLetter) currentLetter else null @@ -463,18 +466,19 @@ class ContactSearchPagedDataSource( @WorkerThread private fun getGroupMembersContactData(section: ContactSearchConfiguration.Section.GroupMembers, query: String?, startIndex: Int, endIndex: Int): List { val groupRecord = section.groupId?.let { contactSearchPagedDataSourceRepository.getGroupRecord(it) } + val recordPredicate = filterByRole(section) return getGroupMembersSearchIterator(section, query).use { records -> readContactData( records = records, - recordsPredicate = filterByRole(section), + recordsPredicate = recordPredicate, section = section, startIndex = startIndex, endIndex = endIndex, recordMapper = { 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, showSelfAsYou = section.showSelfAsYou, showAdminLabel = groupRecord?.isAdmin(recipient) == true) + val headerLetter = if (section.includeLetterHeaders) getHeaderLetterForCurrentRow(it, recordPredicate) else null + ContactSearchData.KnownRecipient(section.sectionKey, recipient, groupsInCommon = groupsInCommon, headerLetter = headerLetter, showSelfAsYou = section.showSelfAsYou, showAdminLabel = groupRecord?.isAdmin(recipient) == true, query = query) } ) } 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 8e24c30123..807f5bd691 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt @@ -4622,12 +4622,12 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da /** * By default, SQLite will prefer numbers over letters when sorting. e.g. (b, a, 1) is sorted as (1, a, b). - * This order by will using a GLOB pattern to instead sort it as (a, b, 1). + * This order by will using a GLOB pattern to instead sort it as (a, b, 1). We also put null names (eg deleted accounts) at the end * * @param column The name of the column to sort by */ private fun orderByPreferringAlphaOverNumeric(column: String): String { - return "CASE WHEN $column GLOB '[0-9]*' THEN 1 ELSE 0 END, $column" + return "CASE WHEN $column IS NULL THEN 2 WHEN $column GLOB '[0-9]*' THEN 1 ELSE 0 END, $column" } private fun Optional.isAbsent(): Boolean { 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 676d632c9f..9227902f5a 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 @@ -13,7 +13,6 @@ import androidx.compose.foundation.shape.CircleShape 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 @@ -29,6 +28,7 @@ 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.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringArrayResource import androidx.compose.ui.res.stringResource @@ -45,7 +45,6 @@ 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 @@ -292,9 +291,13 @@ fun MemberSearchScreen( 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) + imageVector = ImageVector.vectorResource(R.drawable.symbol_filter_20), + tint = if (filterActive) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface, + contentDescription = stringResource(R.string.MemberSearchFragment__filter), + modifier = Modifier + .size(26.dp) + .background(color = if (filterActive) MaterialTheme.colorScheme.primary else Color.Transparent, shape = CircleShape) + .padding(3.dp) ) } } @@ -375,13 +378,17 @@ private fun MemberSearchContent( modifier = modifier.fillMaxSize() ) { val query by contactViewModel.query.collectAsStateWithLifecycle() + val hint = when (memberFilter) { + MemberSearchViewModel.MemberFilter.ALL -> stringResource(R.string.MemberSearchFragment__search_members) + MemberSearchViewModel.MemberFilter.ADMINS -> stringResource(R.string.MemberSearchFragment__search_admins) + MemberSearchViewModel.MemberFilter.CONTACTS -> stringResource(R.string.MemberSearchFragment__search_contacts) + } RecipientSearchBar( - hint = stringResource(R.string.MemberSearchFragment__search_members), + hint = hint, modifier = Modifier .fillMaxWidth() - .padding(vertical = 12.dp) - .focusRequester(focusRequester) - .horizontalGutters(), + .padding(vertical = 12.dp, horizontal = 16.dp) + .focusRequester(focusRequester), query = query ?: "", onQueryChange = { contactViewModel.setQuery(it) }, onSearch = { contactViewModel.setQuery(it) } diff --git a/app/src/main/res/drawable/symbol_filter_20.xml b/app/src/main/res/drawable/symbol_filter_20.xml new file mode 100644 index 0000000000..5b590fc7a4 --- /dev/null +++ b/app/src/main/res/drawable/symbol_filter_20.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c364edf62a..73fd21bec0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2892,6 +2892,8 @@ No results found for \'%s\' + + No results found Shake detected @@ -9890,6 +9892,10 @@ Search members + + Search contacts + + Search admins Filter by