Add group member results to contact search.

This commit is contained in:
Alex Hart 2023-01-24 10:50:28 -04:00 committed by Greyson Parrelli
parent eaeeb08987
commit c022172ace
10 changed files with 184 additions and 9 deletions

View file

@ -131,6 +131,14 @@ public class ContactRepository {
return new SearchCursorWrapper(cursor, SEARCH_CURSOR_MAPPERS);
}
@WorkerThread
public @NonNull Cursor queryGroupMemberContacts(@NonNull String query) {
Cursor cursor = TextUtils.isEmpty(query) ? recipientTable.getGroupMemberContacts()
: recipientTable.queryGroupMemberContacts(query);
return new SearchCursorWrapper(cursor, SEARCH_CURSOR_MAPPERS);
}
private @NonNull Cursor handleNoteToSelfQuery(@NonNull String query, boolean includeSelf, Cursor cursor) {
if (includeSelf && noteToSelfTitle.toLowerCase().contains(query.toLowerCase())) {
Recipient self = Recipient.self();

View file

@ -272,10 +272,13 @@ open class ContactSearchAdapter(
override fun getRecipient(model: RecipientModel): Recipient = model.knownRecipient.recipient
override fun bindNumberField(model: RecipientModel) {
val recipient = getRecipient(model)
if (model.shortSummary && recipient.isGroup) {
if (model.knownRecipient.sectionKey == ContactSearchConfiguration.SectionKey.GROUP_MEMBERS) {
number.text = model.knownRecipient.groupsInCommon.toDisplayText(context)
number.visible = true
} else if (model.shortSummary && recipient.isGroup) {
val count = recipient.participantIds.size
number.text = context.resources.getQuantityString(R.plurals.ContactSearchItems__group_d_members, count, count)
number.visible = true
} else {
super.bindNumberField(model)
}
@ -404,6 +407,7 @@ open class ContactSearchAdapter(
ContactSearchConfiguration.SectionKey.INDIVIDUALS -> R.string.ContactsCursorLoader_contacts
ContactSearchConfiguration.SectionKey.GROUPS -> R.string.ContactsCursorLoader_groups
ContactSearchConfiguration.SectionKey.ARBITRARY -> error("This section does not support HEADER")
ContactSearchConfiguration.SectionKey.GROUP_MEMBERS -> R.string.ContactsCursorLoader_group_members
}
)

View file

@ -76,17 +76,47 @@ class ContactSearchConfiguration private constructor(
override val includeHeader: Boolean = false
override val expandConfig: ExpandConfig? = null
}
data class GroupMembers(
override val includeHeader: Boolean = true,
override val expandConfig: ExpandConfig? = null
) : Section(SectionKey.GROUP_MEMBERS)
}
/**
* Describes a given section. Useful for labeling sections and managing expansion state.
*/
enum class SectionKey {
/**
* Lists My Stories, distribution lists, as well as group stories.
*/
STORIES,
/**
* Recent chats.
*/
RECENTS,
/**
* 1:1 Contacts with whom I've started a chat.
*/
INDIVIDUALS,
/**
* Active groups the user is a member of
*/
GROUPS,
ARBITRARY
/**
* Arbitrary row (think new group button, username row, etc)
*/
ARBITRARY,
/**
* Contacts that are members of groups user is in that they've not explicitly
* started a conversation with.
*/
GROUP_MEMBERS
}
/**

View file

@ -25,9 +25,11 @@ sealed class ContactSearchData(val contactSearchKey: ContactSearchKey) {
* A row displaying a known recipient.
*/
data class KnownRecipient(
val sectionKey: ContactSearchConfiguration.SectionKey,
val recipient: Recipient,
val shortSummary: Boolean = false,
val headerLetter: String? = null
val headerLetter: String? = null,
val groupsInCommon: GroupsInCommon = GroupsInCommon(0, listOf())
) : ContactSearchData(ContactSearchKey.RecipientSearchKey(recipient.id, false))
/**

View file

@ -91,6 +91,7 @@ class ContactSearchPagedDataSource(
is ContactSearchConfiguration.Section.Recents -> getRecentsSearchIterator(section, query).getCollectionSize(section, query, null)
is ContactSearchConfiguration.Section.Stories -> getStoriesSearchIterator(query).getCollectionSize(section, query, null)
is ContactSearchConfiguration.Section.Arbitrary -> arbitraryRepository?.getSize(section, query) ?: error("Invalid arbitrary section.")
is ContactSearchConfiguration.Section.GroupMembers -> getGroupMembersSearchIterator(query).getCollectionSize(section, query, null)
}
}
@ -122,6 +123,7 @@ class ContactSearchPagedDataSource(
is ContactSearchConfiguration.Section.Recents -> getRecentsContactData(section, query, startIndex, endIndex)
is ContactSearchConfiguration.Section.Stories -> getStoriesContactData(section, query, startIndex, endIndex)
is ContactSearchConfiguration.Section.Arbitrary -> arbitraryRepository?.getData(section, query, startIndex, endIndex) ?: error("Invalid arbitrary section.")
is ContactSearchConfiguration.Section.GroupMembers -> getGroupMembersContactData(section, query, startIndex, endIndex)
}
}
@ -152,6 +154,10 @@ class ContactSearchPagedDataSource(
return CursorSearchIterator(contactSearchPagedDataSourceRepository.getRecents(section))
}
private fun getGroupMembersSearchIterator(query: String?): ContactSearchIterator<Cursor> {
return CursorSearchIterator(contactSearchPagedDataSourceRepository.queryGroupMemberContacts(query))
}
private fun <R> readContactData(
records: ContactSearchIterator<R>,
recordsPredicate: ((R) -> Boolean)?,
@ -197,7 +203,7 @@ class ContactSearchPagedDataSource(
startIndex = startIndex,
endIndex = endIndex,
recordMapper = {
ContactSearchData.KnownRecipient(contactSearchPagedDataSourceRepository.getRecipientFromThreadCursor(it))
ContactSearchData.KnownRecipient(section.sectionKey, contactSearchPagedDataSourceRepository.getRecipientFromThreadCursor(it))
}
)
}
@ -219,7 +225,7 @@ class ContactSearchPagedDataSource(
endIndex = endIndex,
recordMapper = {
val recipient = contactSearchPagedDataSourceRepository.getRecipientFromRecipientCursor(it)
ContactSearchData.KnownRecipient(recipient, headerLetter = headerMap[recipient.id])
ContactSearchData.KnownRecipient(section.sectionKey, recipient, headerLetter = headerMap[recipient.id])
}
)
}
@ -237,7 +243,7 @@ class ContactSearchPagedDataSource(
if (section.returnAsGroupStories) {
ContactSearchData.Story(contactSearchPagedDataSourceRepository.getRecipientFromGroupRecord(it), 0, DistributionListPrivacyMode.ALL)
} else {
ContactSearchData.KnownRecipient(contactSearchPagedDataSourceRepository.getRecipientFromGroupRecord(it), shortSummary = section.shortSummary)
ContactSearchData.KnownRecipient(section.sectionKey, contactSearchPagedDataSourceRepository.getRecipientFromGroupRecord(it), shortSummary = section.shortSummary)
}
}
)
@ -254,6 +260,23 @@ class ContactSearchPagedDataSource(
}
}
private fun getGroupMembersContactData(section: ContactSearchConfiguration.Section.GroupMembers, query: String?, startIndex: Int, endIndex: Int): List<ContactSearchData> {
return getGroupMembersSearchIterator(query).use { records ->
readContactData(
records = records,
recordsPredicate = null,
section = section,
startIndex = startIndex,
endIndex = endIndex,
recordMapper = {
val recipient = contactSearchPagedDataSourceRepository.getRecipientFromRecipientCursor(it)
val groupsInCommon = contactSearchPagedDataSourceRepository.getGroupsInCommon(recipient)
ContactSearchData.KnownRecipient(section.sectionKey, recipient, groupsInCommon = groupsInCommon)
}
)
}
}
private fun <R> createResultsCollection(
section: ContactSearchConfiguration.Section,
records: ContactSearchIterator<R>,

View file

@ -48,6 +48,10 @@ open class ContactSearchPagedDataSourceRepository(
return contactRepository.queryNonGroupContacts(query ?: "", includeSelf)
}
open fun queryGroupMemberContacts(query: String?): Cursor? {
return contactRepository.queryGroupMemberContacts(query ?: "")
}
open fun getGroupSearchIterator(
section: ContactSearchConfiguration.Section.Groups,
query: String?
@ -95,6 +99,20 @@ open class ContactSearchPagedDataSourceRepository(
return Recipient.resolved(RecipientId.from(CursorUtil.requireLong(cursor, ContactRepository.ID_COLUMN)))
}
open fun getGroupsInCommon(recipient: Recipient): GroupsInCommon {
val groupsInCommon = SignalDatabase.groups.getPushGroupsContainingMember(recipient.id)
val groupRecipientIds = groupsInCommon.take(2).map { it.recipientId }
val names = Recipient.resolvedList(groupRecipientIds)
.map { it.getDisplayName(context) }
.sorted()
return GroupsInCommon(groupsInCommon.size, names)
}
open fun hasGroupsInCommon(recipient: Recipient): Boolean {
return SignalDatabase.groups.getPushGroupsContainingMember(recipient.id).isNotEmpty()
}
open fun getRecipientFromGroupRecord(groupRecord: GroupRecord): Recipient {
return Recipient.resolved(groupRecord.recipientId)
}

View file

@ -0,0 +1,23 @@
package org.thoughtcrime.securesms.contacts.paged
import android.content.Context
import org.thoughtcrime.securesms.R
/**
* Groups in common helper class
*/
data class GroupsInCommon(
private val total: Int,
private val names: List<String>
) {
fun toDisplayText(context: Context): String {
return when (total) {
1 -> context.getString(R.string.MessageRequestProfileView_member_of_one_group, names[0])
2 -> context.getString(R.string.MessageRequestProfileView_member_of_two_groups, names[0], names[1])
else -> context.getString(
R.string.MessageRequestProfileView_member_of_many_groups, names[0], names[1],
context.resources.getQuantityString(R.plurals.MessageRequestProfileView_member_of_d_additional_groups, total - 2, total - 2)
)
}
}
}

View file

@ -454,6 +454,14 @@ class MultiselectForwardFragment :
)
)
if (!query.isNullOrEmpty()) {
addSection(
ContactSearchConfiguration.Section.GroupMembers(
includeHeader = true
)
)
}
addSection(
ContactSearchConfiguration.Section.Groups(
includeHeader = true,

View file

@ -3120,6 +3120,31 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
return readableDatabase.query(TABLE_NAME, SEARCH_PROJECTION, selection, args, null, null, orderBy)
}
fun getGroupMemberContacts(): Cursor? {
val searchSelection = ContactSearchSelection.Builder()
.withGroupMembers(true)
.excludeId(Recipient.self().id)
.build()
val orderBy = orderByPreferringAlphaOverNumeric(SORT_NAME) + ", " + PHONE
return readableDatabase.query(TABLE_NAME, SEARCH_PROJECTION, searchSelection.where, searchSelection.args, null, null, orderBy)
}
fun queryGroupMemberContacts(inputQuery: String): Cursor? {
val query = SqlUtil.buildCaseInsensitiveGlobPattern(inputQuery)
val searchSelection = ContactSearchSelection.Builder()
.withGroupMembers(true)
.excludeId(Recipient.self().id)
.withSearchQuery(query)
.build()
val selection = searchSelection.where
val args = searchSelection.args
val orderBy = orderByPreferringAlphaOverNumeric(SORT_NAME) + ", " + PHONE
return readableDatabase.query(TABLE_NAME, SEARCH_PROJECTION, selection, args, null, null, orderBy)
}
fun queryAllContacts(inputQuery: String): Cursor? {
val query = SqlUtil.buildCaseInsensitiveGlobPattern(inputQuery)
val selection =
@ -4126,6 +4151,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
internal class Builder {
private var includeRegistered = false
private var includeNonRegistered = false
private var includeGroupMembers = false
private var excludeId: RecipientId? = null
private var excludeGroups = false
private var searchQuery: String? = null
@ -4140,6 +4166,11 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
return this
}
fun withGroupMembers(includeGroupMembers: Boolean): Builder {
this.includeGroupMembers = includeGroupMembers
return this
}
fun excludeId(recipientId: RecipientId?): Builder {
excludeId = recipientId
return this
@ -4156,11 +4187,13 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
}
fun build(): ContactSearchSelection {
check(!(!includeRegistered && !includeNonRegistered)) { "Must include either registered or non-registered recipients in search" }
check(!(!includeRegistered && !includeNonRegistered && !includeGroupMembers)) { "Must include either registered, non-registered, or group member recipients in search" }
val stringBuilder = StringBuilder("(")
val args: MutableList<Any?> = LinkedList()
var hasPreceedingSection = false
if (includeRegistered) {
hasPreceedingSection = true
stringBuilder.append("(")
args.add(RegisteredState.REGISTERED.id)
args.add(1)
@ -4175,11 +4208,12 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
stringBuilder.append(")")
}
if (includeRegistered && includeNonRegistered) {
if (hasPreceedingSection && includeNonRegistered) {
stringBuilder.append(" OR ")
}
if (includeNonRegistered) {
hasPreceedingSection = true
stringBuilder.append("(")
args.add(RegisteredState.REGISTERED.id)
@ -4195,6 +4229,26 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
stringBuilder.append(")")
}
if (hasPreceedingSection && includeGroupMembers) {
stringBuilder.append(" OR ")
}
if (includeGroupMembers) {
stringBuilder.append("(")
args.add(RegisteredState.REGISTERED.id)
args.add(1)
if (Util.isEmpty(searchQuery)) {
stringBuilder.append(GROUP_MEMBER_CONTACT)
} else {
stringBuilder.append(QUERY_GROUP_MEMBER_CONTACT)
args.add(searchQuery)
args.add(searchQuery)
args.add(searchQuery)
}
stringBuilder.append(")")
}
stringBuilder.append(")")
stringBuilder.append(FILTER_BLOCKED)
args.add(0)
@ -4216,6 +4270,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
}
companion object {
const val HAS_GROUP_IN_COMMON = "EXISTS (SELECT 1 FROM ${GroupTable.MembershipTable.TABLE_NAME} WHERE ${GroupTable.MembershipTable.TABLE_NAME}.${GroupTable.MembershipTable.RECIPIENT_ID} = $ID)"
const val FILTER_GROUPS = " AND $GROUP_ID IS NULL"
const val FILTER_ID = " AND $ID != ?"
const val FILTER_BLOCKED = " AND $BLOCKED = ?"
@ -4224,6 +4279,8 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
const val QUERY_NON_SIGNAL_CONTACT = "$NON_SIGNAL_CONTACT AND ($PHONE GLOB ? OR $EMAIL GLOB ? OR $SYSTEM_JOINED_NAME GLOB ?)"
const val SIGNAL_CONTACT = "$REGISTERED = ? AND (NULLIF($SYSTEM_JOINED_NAME, '') NOT NULL OR $PROFILE_SHARING = ?) AND ($SORT_NAME NOT NULL OR $USERNAME NOT NULL)"
const val QUERY_SIGNAL_CONTACT = "$SIGNAL_CONTACT AND ($PHONE GLOB ? OR $SORT_NAME GLOB ? OR $USERNAME GLOB ?)"
const val GROUP_MEMBER_CONTACT = "$REGISTERED = ? AND $HAS_GROUP_IN_COMMON AND NOT (NULLIF($SYSTEM_JOINED_NAME, '') NOT NULL OR $PROFILE_SHARING = ?) AND ($SORT_NAME NOT NULL OR $USERNAME NOT NULL)"
const val QUERY_GROUP_MEMBER_CONTACT = "$GROUP_MEMBER_CONTACT AND ($PHONE GLOB ? OR $SORT_NAME GLOB ? OR $USERNAME GLOB ?)"
}
}

View file

@ -224,6 +224,8 @@
<string name="ContactsCursorLoader_recent_chats">Recent chats</string>
<string name="ContactsCursorLoader_contacts">Contacts</string>
<string name="ContactsCursorLoader_groups">Groups</string>
<!-- Contact search header for individuals who the user has not started a conversation with but is in a group with -->
<string name="ContactsCursorLoader_group_members">Group members</string>
<string name="ContactsCursorLoader_phone_number_search">Phone number search</string>
<!-- Header for username search -->
<string name="ContactsCursorLoader_find_by_username">Find by username</string>