Update group membership search.

This commit is contained in:
Michelle Tang
2026-07-08 11:59:20 -04:00
committed by Alex Hart
parent cc72a2232e
commit 636247f83a
8 changed files with 74 additions and 24 deletions
@@ -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
@@ -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))
/**
@@ -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))
@@ -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<ContactSearchData> {
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)
}
)
}
@@ -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 <T> Optional<T>.isAbsent(): Boolean {
@@ -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) }