Add "Group Members" section to ConversationList search results.

This commit is contained in:
Alex Hart 2023-01-26 14:10:38 -04:00 committed by Greyson Parrelli
parent e84c6187b9
commit 09902e5d11
14 changed files with 265 additions and 39 deletions

View file

@ -33,11 +33,12 @@ import org.thoughtcrime.securesms.util.visible
open class ContactSearchAdapter(
displayCheckBox: Boolean,
displaySmsTag: DisplaySmsTag,
recipientListener: (View, ContactSearchData.KnownRecipient, Boolean) -> Unit,
storyListener: (View, ContactSearchData.Story, Boolean) -> Unit,
recipientListener: Listener<ContactSearchData.KnownRecipient>,
storyListener: Listener<ContactSearchData.Story>,
storyContextMenuCallbacks: StoryContextMenuCallbacks,
expandListener: (ContactSearchData.Expand) -> Unit
) : PagingMappingAdapter<ContactSearchKey>() {
init {
registerStoryItems(this, displayCheckBox, storyListener, storyContextMenuCallbacks)
registerKnownRecipientItems(this, displayCheckBox, displaySmsTag, recipientListener)
@ -49,7 +50,7 @@ open class ContactSearchAdapter(
fun registerStoryItems(
mappingAdapter: MappingAdapter,
displayCheckBox: Boolean = false,
storyListener: (View, ContactSearchData.Story, Boolean) -> Unit,
storyListener: Listener<ContactSearchData.Story>,
storyContextMenuCallbacks: StoryContextMenuCallbacks? = null
) {
mappingAdapter.registerFactory(
@ -62,7 +63,7 @@ open class ContactSearchAdapter(
mappingAdapter: MappingAdapter,
displayCheckBox: Boolean,
displaySmsTag: DisplaySmsTag,
recipientListener: (View, ContactSearchData.KnownRecipient, Boolean) -> Unit
recipientListener: Listener<ContactSearchData.KnownRecipient>
) {
mappingAdapter.registerFactory(
RecipientModel::class.java,
@ -97,6 +98,7 @@ open class ContactSearchAdapter(
is ContactSearchData.Message -> MessageModel(it)
is ContactSearchData.Thread -> ThreadModel(it)
is ContactSearchData.Empty -> EmptyModel(it)
is ContactSearchData.GroupWithMembers -> GroupWithMembersModel(it)
}
}
)
@ -133,7 +135,7 @@ open class ContactSearchAdapter(
private class StoryViewHolder(
itemView: View,
displayCheckBox: Boolean,
onClick: (View, ContactSearchData.Story, Boolean) -> Unit,
onClick: Listener<ContactSearchData.Story>,
private val storyContextMenuCallbacks: StoryContextMenuCallbacks?
) : BaseRecipientViewHolder<StoryModel, ContactSearchData.Story>(itemView, displayCheckBox, DisplaySmsTag.NEVER, onClick) {
override fun isSelected(model: StoryModel): Boolean = model.isSelected
@ -265,7 +267,7 @@ open class ContactSearchAdapter(
itemView: View,
displayCheckBox: Boolean,
displaySmsTag: DisplaySmsTag,
onClick: (View, ContactSearchData.KnownRecipient, Boolean) -> Unit
onClick: Listener<ContactSearchData.KnownRecipient>
) : BaseRecipientViewHolder<RecipientModel, ContactSearchData.KnownRecipient>(itemView, displayCheckBox, displaySmsTag, onClick), LetterHeaderDecoration.LetterHeaderItem {
private var headerLetter: String? = null
@ -302,7 +304,7 @@ open class ContactSearchAdapter(
itemView: View,
private val displayCheckBox: Boolean,
private val displaySmsTag: DisplaySmsTag,
val onClick: (View, D, Boolean) -> Unit
val onClick: Listener<D>
) : MappingViewHolder<T>(itemView) {
protected val avatar: AvatarImageView = itemView.findViewById(R.id.contact_photo_image)
@ -316,7 +318,7 @@ open class ContactSearchAdapter(
override fun bind(model: T) {
checkbox.visible = displayCheckBox
checkbox.isChecked = isSelected(model)
itemView.setOnClickListener { onClick(avatar, getData(model), isSelected(model)) }
itemView.setOnClickListener { onClick.listen(avatar, getData(model), isSelected(model)) }
bindLongPress(model)
if (payload.isNotEmpty()) {
@ -420,6 +422,15 @@ open class ContactSearchAdapter(
override fun areContentsTheSame(newItem: EmptyModel): Boolean = newItem.empty == empty
}
/**
* Mapping Model for [ContactSearchData.GroupWithMembers]
*/
class GroupWithMembersModel(val groupWithMembers: ContactSearchData.GroupWithMembers) : MappingModel<GroupWithMembersModel> {
override fun areContentsTheSame(newItem: GroupWithMembersModel): Boolean = newItem.groupWithMembers == groupWithMembers
override fun areItemsTheSame(newItem: GroupWithMembersModel): Boolean = newItem.groupWithMembers.contactSearchKey == groupWithMembers.contactSearchKey
}
/**
* View Holder for section headers
*/
@ -439,6 +450,7 @@ open class ContactSearchAdapter(
ContactSearchConfiguration.SectionKey.GROUP_MEMBERS -> R.string.ContactsCursorLoader_group_members
ContactSearchConfiguration.SectionKey.CHATS -> R.string.ContactsCursorLoader__chats
ContactSearchConfiguration.SectionKey.MESSAGES -> R.string.ContactsCursorLoader__messages
ContactSearchConfiguration.SectionKey.GROUPS_WITH_MEMBERS -> R.string.ContactsCursorLoader_group_members
}
)
@ -495,4 +507,8 @@ open class ContactSearchAdapter(
IF_NOT_REGISTERED,
NEVER
}
fun interface Listener<D : ContactSearchData> {
fun listen(view: View, data: D, isSelected: Boolean)
}
}

View file

@ -83,6 +83,18 @@ class ContactSearchConfiguration private constructor(
override val expandConfig: ExpandConfig? = null
) : Section(SectionKey.GROUP_MEMBERS)
/**
* Includes a list of groups with members whose search name match the search query.
* This section will only be rendered if there is a non-null, non-empty query present.
*
* Key: [ContactSearchKey.GroupWithMembers]
* Data: [ContactSearchData.GroupWithMembers]
*/
data class GroupsWithMembers(
override val includeHeader: Boolean = true,
override val expandConfig: ExpandConfig? = null
) : Section(SectionKey.GROUPS_WITH_MEMBERS)
data class Chats(
val isUnreadOnly: Boolean = false,
override val includeHeader: Boolean = true,
@ -119,6 +131,11 @@ class ContactSearchConfiguration private constructor(
*/
GROUPS,
/**
* Section Key for [Section.GroupsWithMembers]
*/
GROUPS_WITH_MEMBERS,
/**
* Arbitrary row (think new group button, username row, etc)
*/
@ -207,6 +224,13 @@ class ContactSearchConfiguration private constructor(
addSection(Section.Arbitrary(setOf(first) + rest.toSet()))
}
fun groupsWithMembers(
includeHeader: Boolean = true,
expandConfig: ExpandConfig? = null
) {
addSection(Section.GroupsWithMembers(includeHeader, expandConfig))
}
fun addSection(section: Section)
}
}

View file

@ -4,6 +4,7 @@ import android.os.Bundle
import androidx.annotation.VisibleForTesting
import org.thoughtcrime.securesms.contacts.HeaderAction
import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode
import org.thoughtcrime.securesms.database.model.GroupRecord
import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.search.MessageResult
@ -50,6 +51,16 @@ sealed class ContactSearchData(val contactSearchKey: ContactSearchKey) {
val threadRecord: ThreadRecord
) : ContactSearchData(ContactSearchKey.Thread(threadRecord.threadId))
/**
* A row displaying a group which has members that match the given query.
* Rows of this type are only present if the query is non-empty and non-null.
*/
data class GroupWithMembers(
val query: String,
val groupRecord: GroupRecord,
val date: Long
) : ContactSearchData(ContactSearchKey.GroupWithMembers(groupRecord.id))
/**
* A row containing a title for a given section
*/

View file

@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.contacts.paged
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sharing.ShareContact
@ -48,6 +49,11 @@ sealed class ContactSearchKey {
*/
data class Thread(val threadId: Long) : ContactSearchKey()
/**
* Search key for [ContactSearchData.GroupWithMembers]
*/
data class GroupWithMembers(val groupId: GroupId) : ContactSearchKey()
/**
* Search key for a MessageRecord
*/

View file

@ -168,8 +168,8 @@ class ContactSearchMediator(
fun create(
displayCheckBox: Boolean,
displaySmsTag: ContactSearchAdapter.DisplaySmsTag,
recipientListener: (View, ContactSearchData.KnownRecipient, Boolean) -> Unit,
storyListener: (View, ContactSearchData.Story, Boolean) -> Unit,
recipientListener: ContactSearchAdapter.Listener<ContactSearchData.KnownRecipient>,
storyListener: ContactSearchAdapter.Listener<ContactSearchData.Story>,
storyContextMenuCallbacks: ContactSearchAdapter.StoryContextMenuCallbacks,
expandListener: (ContactSearchData.Expand) -> Unit
): PagingMappingAdapter<ContactSearchKey>
@ -179,8 +179,8 @@ class ContactSearchMediator(
override fun create(
displayCheckBox: Boolean,
displaySmsTag: ContactSearchAdapter.DisplaySmsTag,
recipientListener: (View, ContactSearchData.KnownRecipient, Boolean) -> Unit,
storyListener: (View, ContactSearchData.Story, Boolean) -> Unit,
recipientListener: ContactSearchAdapter.Listener<ContactSearchData.KnownRecipient>,
storyListener: ContactSearchAdapter.Listener<ContactSearchData.Story>,
storyContextMenuCallbacks: ContactSearchAdapter.StoryContextMenuCallbacks,
expandListener: (ContactSearchData.Expand) -> Unit
): PagingMappingAdapter<ContactSearchKey> {

View file

@ -1,11 +1,13 @@
package org.thoughtcrime.securesms.contacts.paged
import android.database.Cursor
import org.signal.core.util.requireLong
import org.signal.paging.PagedDataSource
import org.thoughtcrime.securesms.contacts.paged.collections.ContactSearchCollection
import org.thoughtcrime.securesms.contacts.paged.collections.ContactSearchIterator
import org.thoughtcrime.securesms.contacts.paged.collections.CursorSearchIterator
import org.thoughtcrime.securesms.contacts.paged.collections.StoriesSearchCollection
import org.thoughtcrime.securesms.database.GroupTable
import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode
import org.thoughtcrime.securesms.database.model.GroupRecord
import org.thoughtcrime.securesms.database.model.ThreadRecord
@ -112,6 +114,7 @@ class ContactSearchPagedDataSource(
is ContactSearchConfiguration.Section.GroupMembers -> getGroupMembersSearchIterator(query).getCollectionSize(section, query, null)
is ContactSearchConfiguration.Section.Chats -> getThreadData(query, section.isUnreadOnly).getCollectionSize(section, query, null)
is ContactSearchConfiguration.Section.Messages -> getMessageData(query).getCollectionSize(section, query, null)
is ContactSearchConfiguration.Section.GroupsWithMembers -> getGroupsWithMembersIterator(query).getCollectionSize(section, query, null)
}
}
@ -146,6 +149,7 @@ class ContactSearchPagedDataSource(
is ContactSearchConfiguration.Section.GroupMembers -> getGroupMembersContactData(section, query, startIndex, endIndex)
is ContactSearchConfiguration.Section.Chats -> getThreadContactData(section, query, startIndex, endIndex)
is ContactSearchConfiguration.Section.Messages -> getMessageContactData(section, query, startIndex, endIndex)
is ContactSearchConfiguration.Section.GroupsWithMembers -> getGroupsWithMembersContactData(section, query, startIndex, endIndex)
}
}
@ -168,6 +172,14 @@ class ContactSearchPagedDataSource(
return CursorSearchIterator(contactSearchPagedDataSourceRepository.getStories(query))
}
private fun getGroupsWithMembersIterator(query: String?): ContactSearchIterator<Cursor> {
return if (query.isNullOrEmpty()) {
CursorSearchIterator(null)
} else {
CursorSearchIterator(contactSearchPagedDataSourceRepository.getGroupsWithMembers(query))
}
}
private fun getRecentsSearchIterator(section: ContactSearchConfiguration.Section.Recents, query: String?): ContactSearchIterator<Cursor> {
if (!query.isNullOrEmpty()) {
throw IllegalArgumentException("Searching Recents is not supported")
@ -216,6 +228,22 @@ class ContactSearchPagedDataSource(
}
}
private fun getGroupsWithMembersContactData(section: ContactSearchConfiguration.Section.GroupsWithMembers, query: String?, startIndex: Int, endIndex: Int): List<ContactSearchData> {
return getGroupsWithMembersIterator(query).use { records ->
readContactData(
records = records,
recordsPredicate = null,
section = section,
startIndex = startIndex,
endIndex = endIndex,
recordMapper = { cursor ->
val record = GroupTable.Reader(cursor).getCurrent()
ContactSearchData.GroupWithMembers(query!!, record!!, cursor.requireLong(GroupTable.THREAD_DATE))
}
)
}
}
private fun getRecentsContactData(section: ContactSearchConfiguration.Section.Recents, query: String?, startIndex: Int, endIndex: Int): List<ContactSearchData> {
return getRecentsSearchIterator(section, query).use { records ->
readContactData(

View file

@ -84,6 +84,10 @@ open class ContactSearchPagedDataSourceRepository(
return SignalDatabase.distributionLists.getAllListsForContactSelectionUiCursor(query, myStoryContainsQuery(query ?: ""))
}
open fun getGroupsWithMembers(query: String): Cursor {
return SignalDatabase.groups.queryGroupsByMemberName(query)
}
open fun getRecipientFromDistributionListCursor(cursor: Cursor): Recipient {
return Recipient.resolved(RecipientId.from(CursorUtil.requireLong(cursor, DistributionListTables.RECIPIENT_ID)))
}

View file

@ -313,11 +313,12 @@ public class ConversationListFragment extends MainFragment implements ActionMode
expandListener,
(v, t, b) -> {
onConversationClicked(t.getThreadRecord());
return Unit.INSTANCE;
},
(v, m, b) -> {
onMessageClicked(m.getMessageResult());
return Unit.INSTANCE;
},
(v, m, b) -> {
onContactClicked(Recipient.resolved(m.getGroupRecord().getRecipientId()));
},
getViewLifecycleOwner(),
GlideApp.with(this),
@ -600,12 +601,12 @@ public class ConversationListFragment extends MainFragment implements ActionMode
if (TextUtils.isEmpty(state.getQuery())) {
return ContactSearchConfiguration.build(b -> Unit.INSTANCE);
} else {
return ContactSearchConfiguration.build(b -> {
return ContactSearchConfiguration.build(builder -> {
ConversationFilterRequest conversationFilterRequest = state.getConversationFilterRequest();
boolean unreadOnly = conversationFilterRequest != null && conversationFilterRequest.getFilter() == ConversationFilter.UNREAD;
b.setQuery(state.getQuery());
b.addSection(new ContactSearchConfiguration.Section.Chats(
builder.setQuery(state.getQuery());
builder.addSection(new ContactSearchConfiguration.Section.Chats(
unreadOnly,
true,
new ContactSearchConfiguration.ExpandConfig(
@ -615,17 +616,22 @@ public class ConversationListFragment extends MainFragment implements ActionMode
));
if (!unreadOnly) {
builder.addSection(new ContactSearchConfiguration.Section.GroupsWithMembers(
true,
new ContactSearchConfiguration.ExpandConfig(
state.getExpandedSections().contains(ContactSearchConfiguration.SectionKey.GROUPS_WITH_MEMBERS),
(a) -> 5
)
));
// Groups-with-member-section
b.addSection(new ContactSearchConfiguration.Section.Messages(
builder.addSection(new ContactSearchConfiguration.Section.Messages(
true,
null
));
b.setHasEmptyState(true);
builder.setHasEmptyState(true);
} else {
b.arbitrary(
builder.arbitrary(
conversationFilterRequest.getSource() == ConversationFilterSource.DRAG
? ConversationListSearchAdapter.ChatFilterOptions.WITHOUT_TIP.getCode()
: ConversationListSearchAdapter.ChatFilterOptions.WITH_TIP.getCode()

View file

@ -22,6 +22,7 @@ import android.graphics.Typeface;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.SpannableStringBuilder;
import android.text.TextUtils;
import android.text.style.CharacterStyle;
import android.text.style.ForegroundColorSpan;
import android.text.style.StyleSpan;
@ -60,6 +61,7 @@ import org.thoughtcrime.securesms.components.DeliveryStatusView;
import org.thoughtcrime.securesms.components.FromTextView;
import org.thoughtcrime.securesms.components.TypingIndicatorView;
import org.thoughtcrime.securesms.components.emoji.EmojiStrings;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchData;
import org.thoughtcrime.securesms.conversation.MessageStyler;
import org.thoughtcrime.securesms.conversationlist.model.ConversationSet;
import org.thoughtcrime.securesms.database.MessageTypes;
@ -80,10 +82,19 @@ import org.thoughtcrime.securesms.util.ExpirationUtil;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.SearchUtil;
import org.thoughtcrime.securesms.util.SpanUtil;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.stream.Collectors;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
import static org.thoughtcrime.securesms.database.model.LiveUpdateMessage.recipientToStringAsync;
@ -129,6 +140,7 @@ public final class ConversationListItem extends ConstraintLayout implements Bind
private SearchUtil.StyleFactory searchStyleFactory;
private LiveData<SpannableString> displayBody;
private Disposable joinMembersDisposable = Disposable.empty();
public ConversationListItem(Context context) {
this(context, null);
@ -219,6 +231,7 @@ public final class ConversationListItem extends ConstraintLayout implements Bind
observeRecipient(lifecycleOwner, thread.getRecipient().live());
observeDisplayBody(null, null);
joinMembersDisposable.dispose();
if (highlightSubstring != null) {
String name = recipient.get().isSelf() ? getContext().getString(R.string.note_to_self) : recipient.get().getDisplayName(getContext());
@ -277,6 +290,7 @@ public final class ConversationListItem extends ConstraintLayout implements Bind
observeRecipient(lifecycleOwner, contact.live());
observeDisplayBody(null, null);
joinMembersDisposable.dispose();
setSubjectViewText(null);
fromView.setText(contact, SearchUtil.getHighlightedSpan(locale, searchStyleFactory, new SpannableString(contact.getDisplayName(getContext())), highlightSubstring, SearchUtil.MATCH_ALL), true, null);
@ -305,6 +319,7 @@ public final class ConversationListItem extends ConstraintLayout implements Bind
observeRecipient(lifecycleOwner, messageResult.getConversationRecipient().live());
observeDisplayBody(null, null);
joinMembersDisposable.dispose();
setSubjectViewText(null);
fromView.setText(recipient.get(), false);
@ -321,6 +336,46 @@ public final class ConversationListItem extends ConstraintLayout implements Bind
contactPhotoImage.setAvatar(glideRequests, recipient.get(), !batchMode);
}
public void bindGroupWithMembers(@NonNull LifecycleOwner lifecycleOwner,
@NonNull ContactSearchData.GroupWithMembers groupWithMembers,
@NonNull GlideRequests glideRequests,
@NonNull Locale locale)
{
this.glideRequests = glideRequests;
this.locale = locale;
this.highlightSubstring = groupWithMembers.getQuery();
observeRecipient(lifecycleOwner, Recipient.live(groupWithMembers.getGroupRecord().getRecipientId()));
observeDisplayBody(null, null);
joinMembersDisposable.dispose();
joinMembersDisposable = joinMembersToDisplayBody(groupWithMembers.getGroupRecord().getMembers(), groupWithMembers.getQuery()).subscribe(joined -> {
setSubjectViewText(SearchUtil.getHighlightedSpan(locale, searchStyleFactory, joined, highlightSubstring, SearchUtil.MATCH_ALL));
});
fromView.setText(recipient.get(), false);
dateView.setText(DateUtils.getBriefRelativeTimeSpanString(getContext(), locale, groupWithMembers.getDate()));
archivedView.setVisibility(GONE);
unreadIndicator.setVisibility(GONE);
unreadMentions.setVisibility(GONE);
deliveryStatusIndicator.setNone();
alertView.setNone();
setSelectedConversations(new ConversationSet());
setBadgeFromRecipient(recipient.get());
contactPhotoImage.setAvatar(glideRequests, recipient.get(), !batchMode);
}
private @NonNull Single<String> joinMembersToDisplayBody(@NonNull List<RecipientId> members, @NonNull String highlightSubstring) {
return Single.fromCallable(() -> {
return Util.join(Recipient.resolvedList(members)
.stream()
.map(r -> r.getDisplayName(getContext()))
.sorted(new JoinMembersComparator(highlightSubstring))
.limit(5)
.collect(Collectors.toList()), ",");
}).subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread());
}
@Override
public void unbind() {
if (this.recipient != null) {
@ -330,6 +385,7 @@ public final class ConversationListItem extends ConstraintLayout implements Bind
}
observeDisplayBody(null, null);
joinMembersDisposable.dispose();
}
@Override

View file

@ -24,12 +24,13 @@ import java.util.Locale
class ConversationListSearchAdapter(
displayCheckBox: Boolean,
displaySmsTag: DisplaySmsTag,
recipientListener: (View, ContactSearchData.KnownRecipient, Boolean) -> Unit,
storyListener: (View, ContactSearchData.Story, Boolean) -> Unit,
recipientListener: Listener<ContactSearchData.KnownRecipient>,
storyListener: Listener<ContactSearchData.Story>,
storyContextMenuCallbacks: StoryContextMenuCallbacks,
expandListener: (ContactSearchData.Expand) -> Unit,
threadListener: (View, ContactSearchData.Thread, Boolean) -> Unit,
messageListener: (View, ContactSearchData.Message, Boolean) -> Unit,
threadListener: Listener<ContactSearchData.Thread>,
messageListener: Listener<ContactSearchData.Message>,
groupWithMembersListener: Listener<ContactSearchData.GroupWithMembers>,
lifecycleOwner: LifecycleOwner,
glideRequests: GlideRequests,
clearFilterListener: () -> Unit
@ -55,6 +56,10 @@ class ConversationListSearchAdapter(
EmptyModel::class.java,
LayoutFactory({ EmptyViewHolder(it) }, R.layout.conversation_list_empty_search_state)
)
registerFactory(
GroupWithMembersModel::class.java,
LayoutFactory({ GroupWithMembersViewHolder(groupWithMembersListener, lifecycleOwner, glideRequests, it) }, R.layout.conversation_list_item_view)
)
}
private class EmptyViewHolder(
@ -70,14 +75,14 @@ class ConversationListSearchAdapter(
}
private class ThreadViewHolder(
private val threadListener: (View, ContactSearchData.Thread, Boolean) -> Unit,
private val threadListener: Listener<ContactSearchData.Thread>,
private val lifecycleOwner: LifecycleOwner,
private val glideRequests: GlideRequests,
itemView: View
) : MappingViewHolder<ThreadModel>(itemView) {
override fun bind(model: ThreadModel) {
itemView.setOnClickListener {
threadListener(itemView, model.thread, false)
threadListener.listen(itemView, model.thread, false)
}
(itemView as ConversationListItem).bindThread(
@ -93,14 +98,14 @@ class ConversationListSearchAdapter(
}
private class MessageViewHolder(
private val messageListener: (View, ContactSearchData.Message, Boolean) -> Unit,
private val messageListener: Listener<ContactSearchData.Message>,
private val lifecycleOwner: LifecycleOwner,
private val glideRequests: GlideRequests,
itemView: View
) : MappingViewHolder<MessageModel>(itemView) {
override fun bind(model: MessageModel) {
itemView.setOnClickListener {
messageListener(itemView, model.message, false)
messageListener.listen(itemView, model.message, false)
}
(itemView as ConversationListItem).bindMessage(
@ -113,6 +118,26 @@ class ConversationListSearchAdapter(
}
}
private class GroupWithMembersViewHolder(
private val groupWithMembersListener: Listener<ContactSearchData.GroupWithMembers>,
private val lifecycleOwner: LifecycleOwner,
private val glideRequests: GlideRequests,
itemView: View
) : MappingViewHolder<GroupWithMembersModel>(itemView) {
override fun bind(model: GroupWithMembersModel) {
itemView.setOnClickListener {
groupWithMembersListener.listen(itemView, model.groupWithMembers, false)
}
(itemView as ConversationListItem).bindGroupWithMembers(
lifecycleOwner,
model.groupWithMembers,
glideRequests,
Locale.getDefault()
)
}
}
private open class BaseChatFilterMappingModel<T : BaseChatFilterMappingModel<T>>(val options: ChatFilterOptions) : MappingModel<T> {
override fun areItemsTheSame(newItem: T): Boolean = true

View file

@ -0,0 +1,18 @@
package org.thoughtcrime.securesms.conversationlist
/**
* Compares two display names, preferring the one which includes the given [highlightSubstring].
*/
class JoinMembersComparator(private val highlightSubstring: String) : Comparator<String> {
override fun compare(o1: String, o2: String): Int {
val o1ContainsSubstring = o1.contains(highlightSubstring, true)
val o2ContainsSubstring = o2.contains(highlightSubstring, true)
return if (!(o1ContainsSubstring xor o2ContainsSubstring)) {
o1.compareTo(o2)
} else if (o1ContainsSubstring) {
-1
} else {
1
}
}
}

View file

@ -69,6 +69,7 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
private val TAG = Log.tag(GroupTable::class.java)
const val MEMBER_GROUP_CONCAT = "member_group_concat"
const val THREAD_DATE = "thread_date"
const val TABLE_NAME = "groups"
const val ID = "_id"
@ -346,6 +347,24 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
return noMetadata && noMembers
}
fun queryGroupsByMemberName(inputQuery: String): Cursor {
val subquery = recipients.getAllContactsSubquery(inputQuery)
val statement = """
SELECT
DISTINCT $TABLE_NAME.*,
GROUP_CONCAT(${MembershipTable.TABLE_NAME}.${MembershipTable.RECIPIENT_ID}) as $MEMBER_GROUP_CONCAT,
${ThreadTable.TABLE_NAME}.${ThreadTable.DATE} as $THREAD_DATE
FROM $TABLE_NAME
INNER JOIN ${MembershipTable.TABLE_NAME} ON ${MembershipTable.TABLE_NAME}.${MembershipTable.GROUP_ID} = $TABLE_NAME.$GROUP_ID
INNER JOIN ${ThreadTable.TABLE_NAME} ON ${ThreadTable.TABLE_NAME}.${ThreadTable.RECIPIENT_ID} = $TABLE_NAME.$RECIPIENT_ID
WHERE $ACTIVE = 1 AND ${MembershipTable.TABLE_NAME}.${MembershipTable.RECIPIENT_ID} IN (${subquery.where})
GROUP BY ${MembershipTable.TABLE_NAME}.${MembershipTable.GROUP_ID}
ORDER BY $TITLE COLLATE NOCASE ASC
""".toSingleLine()
return databaseHelper.signalReadableDatabase.query(statement, subquery.whereArgs)
}
fun queryGroupsByTitle(inputQuery: String, includeInactive: Boolean, excludeV1: Boolean, excludeMms: Boolean): Reader {
val query = getGroupQueryWhereStatement(inputQuery, includeInactive, excludeV1, excludeMms)
//language=sql

View file

@ -30,6 +30,7 @@ import org.signal.core.util.requireLong
import org.signal.core.util.requireNonNullString
import org.signal.core.util.requireString
import org.signal.core.util.select
import org.signal.core.util.toSingleLine
import org.signal.core.util.update
import org.signal.core.util.withinTransaction
import org.signal.libsignal.protocol.IdentityKey
@ -3179,6 +3180,27 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
return readableDatabase.query(TABLE_NAME, SEARCH_PROJECTION, selection, args, null, null, null)
}
/**
* Gets the query used for performing the all contacts search so that it can be injected as a subquery.
*/
fun getAllContactsSubquery(inputQuery: String): SqlUtil.Query {
val query = SqlUtil.buildCaseInsensitiveGlobPattern(inputQuery)
//language=sql
val subquery = """SELECT $ID FROM (
SELECT ${SEARCH_PROJECTION.joinToString(",")} FROM $TABLE_NAME
WHERE $BLOCKED = ? AND $HIDDEN = ? AND
(
$SORT_NAME GLOB ? OR
$USERNAME GLOB ? OR
$PHONE GLOB ? OR
$EMAIL GLOB ?
))
""".toSingleLine()
return SqlUtil.Query(subquery, SqlUtil.buildArgs(0, 0, query, query, query, query))
}
@JvmOverloads
fun queryRecipientsForMentions(inputQuery: String, recipientIds: List<RecipientId>? = null): List<Recipient> {
val query = SqlUtil.buildCaseInsensitiveGlobPattern(inputQuery)

View file

@ -190,19 +190,10 @@ public class SearchRepository {
}
}
Set<RecipientId> groupsByMemberIds = new LinkedHashSet<>();
try (GroupTable.Reader reader = SignalDatabase.groups().queryGroupsByMembership(filteredContacts, true, false, false)) {
while ((record = reader.getNext()) != null) {
groupsByMemberIds.add(record.getRecipientId());
}
}
LinkedHashSet<ThreadRecord> output = new LinkedHashSet<>();
output.addAll(getMatchingThreads(contactIds, unreadOnly));
output.addAll(getMatchingThreads(groupsByTitleIds, unreadOnly));
output.addAll(getMatchingThreads(groupsByMemberIds, unreadOnly));
return new ArrayList<>(output);
}