diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchAdapter.kt index ebe991c518..51e82d3d60 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchAdapter.kt @@ -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, + storyListener: Listener, storyContextMenuCallbacks: StoryContextMenuCallbacks, expandListener: (ContactSearchData.Expand) -> Unit ) : PagingMappingAdapter() { + 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, 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 ) { 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, private val storyContextMenuCallbacks: StoryContextMenuCallbacks? ) : BaseRecipientViewHolder(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 ) : BaseRecipientViewHolder(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 ) : MappingViewHolder(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 { + 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 { + fun listen(view: View, data: D, isSelected: Boolean) + } } 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 db15a20664..a78047e805 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 @@ -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) } } 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 3c624ee3f5..1c4a6a1c6d 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 @@ -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 */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchKey.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchKey.kt index afd38044b0..da64b69591 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchKey.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchKey.kt @@ -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 */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchMediator.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchMediator.kt index 087b86514a..2936e7dd8f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchMediator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchMediator.kt @@ -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, + storyListener: ContactSearchAdapter.Listener, storyContextMenuCallbacks: ContactSearchAdapter.StoryContextMenuCallbacks, expandListener: (ContactSearchData.Expand) -> Unit ): PagingMappingAdapter @@ -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, + storyListener: ContactSearchAdapter.Listener, storyContextMenuCallbacks: ContactSearchAdapter.StoryContextMenuCallbacks, expandListener: (ContactSearchData.Expand) -> Unit ): PagingMappingAdapter { 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 216faf8d54..de0d78ffdd 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 @@ -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 { + return if (query.isNullOrEmpty()) { + CursorSearchIterator(null) + } else { + CursorSearchIterator(contactSearchPagedDataSourceRepository.getGroupsWithMembers(query)) + } + } + private fun getRecentsSearchIterator(section: ContactSearchConfiguration.Section.Recents, query: String?): ContactSearchIterator { 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 { + 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 { return getRecentsSearchIterator(section, query).use { records -> readContactData( 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 9a3cd9beb4..6d85c1b0d6 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 @@ -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))) } 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 8fe20a586c..37f86fe3ca 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -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() diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java index f8e5e243b5..285e8b8a9a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java @@ -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 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 joinMembersToDisplayBody(@NonNull List 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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListSearchAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListSearchAdapter.kt index 811b7d25cf..23efec712b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListSearchAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListSearchAdapter.kt @@ -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, + storyListener: Listener, storyContextMenuCallbacks: StoryContextMenuCallbacks, expandListener: (ContactSearchData.Expand) -> Unit, - threadListener: (View, ContactSearchData.Thread, Boolean) -> Unit, - messageListener: (View, ContactSearchData.Message, Boolean) -> Unit, + threadListener: Listener, + messageListener: Listener, + groupWithMembersListener: Listener, 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, private val lifecycleOwner: LifecycleOwner, private val glideRequests: GlideRequests, itemView: View ) : MappingViewHolder(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, private val lifecycleOwner: LifecycleOwner, private val glideRequests: GlideRequests, itemView: View ) : MappingViewHolder(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, + private val lifecycleOwner: LifecycleOwner, + private val glideRequests: GlideRequests, + itemView: View + ) : MappingViewHolder(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>(val options: ChatFilterOptions) : MappingModel { override fun areItemsTheSame(newItem: T): Boolean = true diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/JoinMembersComparator.kt b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/JoinMembersComparator.kt new file mode 100644 index 0000000000..d613bfd1b2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/JoinMembersComparator.kt @@ -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 { + 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 + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.kt index 7e7b47fb44..fde53e9063 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.kt @@ -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 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 4dff4e30e4..f89f21338d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt @@ -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? = null): List { val query = SqlUtil.buildCaseInsensitiveGlobPattern(inputQuery) diff --git a/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java b/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java index 74bdc14eb2..425f058f8c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java @@ -190,19 +190,10 @@ public class SearchRepository { } } - Set groupsByMemberIds = new LinkedHashSet<>(); - - try (GroupTable.Reader reader = SignalDatabase.groups().queryGroupsByMembership(filteredContacts, true, false, false)) { - while ((record = reader.getNext()) != null) { - groupsByMemberIds.add(record.getRecipientId()); - } - } - LinkedHashSet output = new LinkedHashSet<>(); output.addAll(getMatchingThreads(contactIds, unreadOnly)); output.addAll(getMatchingThreads(groupsByTitleIds, unreadOnly)); - output.addAll(getMatchingThreads(groupsByMemberIds, unreadOnly)); return new ArrayList<>(output); }