Add support for message and thread results.

This commit is contained in:
Alex Hart
2023-01-23 14:38:49 -04:00
committed by Greyson Parrelli
parent 8dd1d3bdeb
commit b4a34599d7
20 changed files with 575 additions and 479 deletions

View File

@@ -11,7 +11,13 @@ interface ArbitraryRepository {
/**
* Get the data for the given arbitrary rows within the start and end index.
*/
fun getData(section: ContactSearchConfiguration.Section.Arbitrary, query: String?, startIndex: Int, endIndex: Int): List<ContactSearchData.Arbitrary>
fun getData(
section: ContactSearchConfiguration.Section.Arbitrary,
query: String?,
startIndex: Int,
endIndex: Int,
totalSearchSize: Int
): List<ContactSearchData.Arbitrary>
/**
* Map an arbitrary object to a mapping model

View File

@@ -94,6 +94,9 @@ open class ContactSearchAdapter(
is ContactSearchData.Header -> HeaderModel(it)
is ContactSearchData.TestRow -> error("This row exists for testing only.")
is ContactSearchData.Arbitrary -> arbitraryRepository?.getMappingModel(it) ?: error("This row must be handled manually")
is ContactSearchData.Message -> MessageModel(it)
is ContactSearchData.Thread -> ThreadModel(it)
is ContactSearchData.Empty -> EmptyModel(it)
}
}
)
@@ -294,7 +297,8 @@ open class ContactSearchAdapter(
/**
* Base Recipient View Holder
*/
private abstract class BaseRecipientViewHolder<T : MappingModel<T>, D : ContactSearchData>(
abstract class BaseRecipientViewHolder<T : MappingModel<T>, D : ContactSearchData>(
itemView: View,
private val displayCheckBox: Boolean,
private val displaySmsTag: DisplaySmsTag,
@@ -391,6 +395,32 @@ open class ContactSearchAdapter(
}
}
/**
* Mapping Model for messages
*/
class MessageModel(val message: ContactSearchData.Message) : MappingModel<MessageModel> {
override fun areItemsTheSame(newItem: MessageModel): Boolean = message.contactSearchKey == newItem.message.contactSearchKey
override fun areContentsTheSame(newItem: MessageModel): Boolean {
return message == newItem.message
}
}
/**
* Mapping Model for threads
*/
class ThreadModel(val thread: ContactSearchData.Thread) : MappingModel<ThreadModel> {
override fun areItemsTheSame(newItem: ThreadModel): Boolean = thread.contactSearchKey == newItem.thread.contactSearchKey
override fun areContentsTheSame(newItem: ThreadModel): Boolean {
return thread == newItem.thread
}
}
class EmptyModel(val empty: ContactSearchData.Empty) : MappingModel<EmptyModel> {
override fun areItemsTheSame(newItem: EmptyModel): Boolean = true
override fun areContentsTheSame(newItem: EmptyModel): Boolean = newItem.empty == empty
}
/**
* View Holder for section headers
*/
@@ -408,6 +438,8 @@ open class ContactSearchAdapter(
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
ContactSearchConfiguration.SectionKey.CHATS -> R.string.ContactsCursorLoader__chats
ContactSearchConfiguration.SectionKey.MESSAGES -> R.string.ContactsCursorLoader__messages
}
)

View File

@@ -7,6 +7,7 @@ import org.thoughtcrime.securesms.contacts.HeaderAction
*/
class ContactSearchConfiguration private constructor(
val query: String?,
val hasEmptyState: Boolean,
val sections: List<Section>
) {
sealed class Section(val sectionKey: SectionKey) {
@@ -81,6 +82,17 @@ class ContactSearchConfiguration private constructor(
override val includeHeader: Boolean = true,
override val expandConfig: ExpandConfig? = null
) : Section(SectionKey.GROUP_MEMBERS)
data class Chats(
val isUnreadOnly: Boolean = false,
override val includeHeader: Boolean = true,
override val expandConfig: ExpandConfig? = null
) : Section(SectionKey.CHATS)
data class Messages(
override val includeHeader: Boolean = true,
override val expandConfig: ExpandConfig? = null
) : Section(SectionKey.MESSAGES)
}
/**
@@ -116,7 +128,17 @@ class ContactSearchConfiguration private constructor(
* Contacts that are members of groups user is in that they've not explicitly
* started a conversation with.
*/
GROUP_MEMBERS
GROUP_MEMBERS,
/**
* 1:1 and Group chats
*/
CHATS,
/**
* Messages from 1:1 and Group chats
*/
MESSAGES
}
/**
@@ -147,6 +169,7 @@ class ContactSearchConfiguration private constructor(
* }
* ```
*/
@JvmStatic
fun build(builderFunction: Builder.() -> Unit): ContactSearchConfiguration {
return ConfigurationBuilder().let {
it.builderFunction()
@@ -162,13 +185,14 @@ class ContactSearchConfiguration private constructor(
private val sections: MutableList<Section> = mutableListOf()
override var query: String? = null
override var hasEmptyState: Boolean = false
override fun addSection(section: Section) {
sections.add(section)
}
fun build(): ContactSearchConfiguration {
return ContactSearchConfiguration(query, sections)
return ContactSearchConfiguration(query, hasEmptyState, sections)
}
}
@@ -177,6 +201,8 @@ class ContactSearchConfiguration private constructor(
*/
interface Builder {
var query: String?
var hasEmptyState: Boolean
fun arbitrary(first: String, vararg rest: String) {
addSection(Section.Arbitrary(setOf(first) + rest.toSet()))
}

View File

@@ -4,7 +4,9 @@ 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.ThreadRecord
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.search.MessageResult
/**
* Represents the data backed by a ContactSearchKey
@@ -32,6 +34,22 @@ sealed class ContactSearchData(val contactSearchKey: ContactSearchKey) {
val groupsInCommon: GroupsInCommon = GroupsInCommon(0, listOf())
) : ContactSearchData(ContactSearchKey.RecipientSearchKey(recipient.id, false))
/**
* A row displaying a message
*/
data class Message(
val query: String,
val messageResult: MessageResult
) : ContactSearchData(ContactSearchKey.Message(messageResult.messageId))
/**
* A row displaying a thread
*/
data class Thread(
val query: String,
val threadRecord: ThreadRecord
) : ContactSearchData(ContactSearchKey.Thread(threadRecord.threadId))
/**
* A row containing a title for a given section
*/
@@ -50,6 +68,11 @@ sealed class ContactSearchData(val contactSearchKey: ContactSearchKey) {
*/
class Arbitrary(val type: String, val data: Bundle? = null) : ContactSearchData(ContactSearchKey.Arbitrary(type))
/**
* Empty state, only included if no other rows exist.
*/
data class Empty(val query: String?) : ContactSearchData(ContactSearchKey.Empty)
/**
* A row which contains an integer, for testing.
*/

View File

@@ -42,4 +42,16 @@ sealed class ContactSearchKey {
* This is used to allow arbitrary extra data to be added to the contact search system.
*/
data class Arbitrary(val type: String) : ContactSearchKey()
/**
* Search key for a ThreadRecord
*/
data class Thread(val threadId: Long) : ContactSearchKey()
/**
* Search key for a MessageRecord
*/
data class Message(val messageId: Long) : ContactSearchKey()
object Empty : ContactSearchKey()
}

View File

@@ -5,23 +5,25 @@ import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterRequest
import org.thoughtcrime.securesms.groups.SelectionLimits
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.search.SearchRepository
import org.thoughtcrime.securesms.stories.settings.custom.PrivateStorySettingsFragment
import org.thoughtcrime.securesms.stories.settings.my.MyStorySettingsFragment
import org.thoughtcrime.securesms.stories.settings.privacy.ChooseInitialMyStoryMembershipBottomSheetDialogFragment
import org.thoughtcrime.securesms.util.Debouncer
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.adapter.mapping.PagingMappingAdapter
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil
import java.util.concurrent.TimeUnit
class ContactSearchMediator(
private val fragment: Fragment,
recyclerView: RecyclerView,
selectionLimits: SelectionLimits,
displayCheckBox: Boolean,
displaySmsTag: ContactSearchAdapter.DisplaySmsTag,
@@ -29,25 +31,31 @@ class ContactSearchMediator(
private val contactSelectionPreFilter: (View?, Set<ContactSearchKey>) -> Set<ContactSearchKey> = { _, s -> s },
performSafetyNumberChecks: Boolean = true,
adapterFactory: AdapterFactory = DefaultAdapterFactory,
arbitraryRepository: ArbitraryRepository? = null
arbitraryRepository: ArbitraryRepository? = null,
) {
private val queryDebouncer = Debouncer(300, TimeUnit.MILLISECONDS)
private val viewModel: ContactSearchViewModel = ViewModelProvider(
fragment,
ContactSearchViewModel.Factory(selectionLimits, ContactSearchRepository(), performSafetyNumberChecks, arbitraryRepository)
ContactSearchViewModel.Factory(
selectionLimits = selectionLimits,
repository = ContactSearchRepository(),
performSafetyNumberChecks = performSafetyNumberChecks,
arbitraryRepository = arbitraryRepository,
searchRepository = SearchRepository(fragment.requireContext().getString(R.string.note_to_self))
)
)[ContactSearchViewModel::class.java]
val adapter = adapterFactory.create(
displayCheckBox = displayCheckBox,
displaySmsTag = displaySmsTag,
recipientListener = this::toggleSelection,
storyListener = this::toggleStorySelection,
storyContextMenuCallbacks = StoryContextMenuCallbacks()
) { viewModel.expandSection(it.sectionKey) }
init {
val adapter = adapterFactory.create(
displayCheckBox,
displaySmsTag,
this::toggleSelection,
this::toggleStorySelection,
StoryContextMenuCallbacks()
) { viewModel.expandSection(it.sectionKey) }
recyclerView.adapter = adapter
val dataAndSelection: LiveData<Pair<List<ContactSearchData>, Set<ContactSearchKey>>> = LiveDataUtil.combineLatest(
viewModel.data,
viewModel.selectionState,
@@ -68,7 +76,13 @@ class ContactSearchMediator(
}
fun onFilterChanged(filter: String?) {
viewModel.setQuery(filter)
queryDebouncer.publish {
viewModel.setQuery(filter)
}
}
fun onConversationFilterRequestChanged(conversationFilterRequest: ConversationFilterRequest) {
viewModel.setConversationFilterRequest(conversationFilterRequest)
}
fun setKeysSelected(keys: Set<ContactSearchKey>) {

View File

@@ -8,10 +8,15 @@ import org.thoughtcrime.securesms.contacts.paged.collections.CursorSearchIterato
import org.thoughtcrime.securesms.contacts.paged.collections.StoriesSearchCollection
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.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.StorySend
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.search.MessageResult
import org.thoughtcrime.securesms.search.MessageSearchResult
import org.thoughtcrime.securesms.search.SearchRepository
import org.thoughtcrime.securesms.search.ThreadSearchResult
import java.util.concurrent.TimeUnit
/**
@@ -20,7 +25,8 @@ import java.util.concurrent.TimeUnit
class ContactSearchPagedDataSource(
private val contactConfiguration: ContactSearchConfiguration,
private val contactSearchPagedDataSourceRepository: ContactSearchPagedDataSourceRepository = ContactSearchPagedDataSourceRepository(ApplicationDependencies.getApplication()),
private val arbitraryRepository: ArbitraryRepository? = null
private val arbitraryRepository: ArbitraryRepository? = null,
private val searchRepository: SearchRepository? = null
) : PagedDataSource<ContactSearchKey, ContactSearchData> {
companion object {
@@ -31,13 +37,26 @@ class ContactSearchPagedDataSource(
private val activeStoryCount = latestStorySends.size
private var searchCache = SearchCache()
private var searchSize = -1
override fun size(): Int {
return contactConfiguration.sections.sumOf {
searchSize = contactConfiguration.sections.sumOf {
getSectionSize(it, contactConfiguration.query)
}
return if (searchSize == 0 && contactConfiguration.hasEmptyState) {
1
} else {
searchSize
}
}
override fun load(start: Int, length: Int, cancellationSignal: PagedDataSource.CancellationSignal): MutableList<ContactSearchData> {
if (searchSize == 0 && contactConfiguration.hasEmptyState) {
return mutableListOf(ContactSearchData.Empty(contactConfiguration.query))
}
val sizeMap: Map<ContactSearchConfiguration.Section, Int> = contactConfiguration.sections.associateWith { getSectionSize(it, contactConfiguration.query) }
val startIndex: Index = findIndex(sizeMap, start)
val endIndex: Index = findIndex(sizeMap, start + length)
@@ -92,6 +111,8 @@ class ContactSearchPagedDataSource(
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)
is ContactSearchConfiguration.Section.Chats -> getThreadData(query, section.isUnreadOnly).getCollectionSize(section, query, null)
is ContactSearchConfiguration.Section.Messages -> getMessageData(query).getCollectionSize(section, query, null)
}
}
@@ -122,8 +143,10 @@ class ContactSearchPagedDataSource(
is ContactSearchConfiguration.Section.Individuals -> getNonGroupContactsData(section, query, startIndex, endIndex)
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.Arbitrary -> arbitraryRepository?.getData(section, query, startIndex, endIndex, searchSize) ?: error("Invalid arbitrary section.")
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)
}
}
@@ -277,6 +300,63 @@ class ContactSearchPagedDataSource(
}
}
private fun getMessageData(query: String?): ContactSearchIterator<MessageResult> {
check(searchRepository != null)
if (searchCache.messageSearchResult == null && query != null) {
searchCache = searchCache.copy(messageSearchResult = searchRepository.queryMessagesSync(query))
}
return if (query != null) {
ListSearchIterator(searchCache.messageSearchResult!!.results)
} else {
ListSearchIterator(emptyList())
}
}
private fun getMessageContactData(section: ContactSearchConfiguration.Section.Messages, query: String?, startIndex: Int, endIndex: Int): List<ContactSearchData> {
return getMessageData(query).use { records ->
readContactData(
records = records,
recordsPredicate = null,
section = section,
startIndex = startIndex,
endIndex = endIndex,
recordMapper = {
ContactSearchData.Message(query ?: "", it)
}
)
}
}
private fun getThreadData(query: String?, unreadOnly: Boolean): ContactSearchIterator<ThreadRecord> {
check(searchRepository != null)
if (searchCache.threadSearchResult == null && query != null) {
searchCache = searchCache.copy(threadSearchResult = searchRepository.queryThreadsSync(query, unreadOnly))
}
return if (query != null) {
ListSearchIterator(searchCache.threadSearchResult!!.results)
} else {
ListSearchIterator(emptyList())
}
}
private fun getThreadContactData(section: ContactSearchConfiguration.Section.Chats, query: String?, startIndex: Int, endIndex: Int): List<ContactSearchData> {
return getThreadData(query, section.isUnreadOnly).use { records ->
readContactData(
records = records,
recordsPredicate = null,
section = section,
startIndex = startIndex,
endIndex = endIndex,
recordMapper = {
ContactSearchData.Thread(query ?: "", it)
}
)
}
}
private fun <R> createResultsCollection(
section: ContactSearchConfiguration.Section,
records: ContactSearchIterator<R>,
@@ -290,6 +370,14 @@ class ContactSearchPagedDataSource(
}
}
/**
* Caches search results of particularly intensive queries.
*/
private data class SearchCache(
val messageSearchResult: MessageSearchResult? = null,
val threadSearchResult: ThreadSearchResult? = null
)
/**
* StoryComparator
*/
@@ -308,4 +396,21 @@ class ContactSearchPagedDataSource(
}
}
}
private class ListSearchIterator<T>(val list: List<T>) : ContactSearchIterator<T> {
private var position = -1
override fun moveToPosition(n: Int) {
position = n
}
override fun getCount(): Int = list.size
override fun hasNext(): Boolean = position < list.lastIndex
override fun next(): T = list[++position]
override fun close() = Unit
}
}

View File

@@ -19,10 +19,8 @@ class ContactSearchRepository {
return Single.fromCallable {
contactSearchKeys.map {
val isSelectable = when (it) {
is ContactSearchKey.Expand -> false
is ContactSearchKey.Header -> false
is ContactSearchKey.RecipientSearchKey -> canSelectRecipient(it.recipientId)
is ContactSearchKey.Arbitrary -> false
else -> false
}
ContactSearchSelectionResult(it, isSelectable)
}.toSet()

View File

@@ -1,10 +1,13 @@
package org.thoughtcrime.securesms.contacts.paged
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterRequest
/**
* Simple search state for contacts.
*/
data class ContactSearchState(
val query: String? = null,
val conversationFilterRequest: ConversationFilterRequest? = null,
val expandedSections: Set<ContactSearchConfiguration.SectionKey> = emptySet(),
val groupStories: Set<ContactSearchData.Story> = emptySet()
)

View File

@@ -13,9 +13,11 @@ import org.signal.paging.LivePagedData
import org.signal.paging.PagedData
import org.signal.paging.PagingConfig
import org.signal.paging.PagingController
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterRequest
import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode
import org.thoughtcrime.securesms.groups.SelectionLimits
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.search.SearchRepository
import org.thoughtcrime.securesms.util.livedata.Store
import org.whispersystems.signalservice.api.util.Preconditions
@@ -27,7 +29,8 @@ class ContactSearchViewModel(
private val contactSearchRepository: ContactSearchRepository,
private val performSafetyNumberChecks: Boolean,
private val safetyNumberRepository: SafetyNumberRepository = SafetyNumberRepository(),
private val arbitraryRepository: ArbitraryRepository?
private val arbitraryRepository: ArbitraryRepository?,
private val searchRepository: SearchRepository
) : ViewModel() {
private val disposables = CompositeDisposable()
@@ -54,7 +57,7 @@ class ContactSearchViewModel(
}
fun setConfiguration(contactSearchConfiguration: ContactSearchConfiguration) {
val pagedDataSource = ContactSearchPagedDataSource(contactSearchConfiguration, arbitraryRepository = arbitraryRepository)
val pagedDataSource = ContactSearchPagedDataSource(contactSearchConfiguration, arbitraryRepository = arbitraryRepository, searchRepository = searchRepository)
pagedData.value = PagedData.createForLiveData(pagedDataSource, pagingConfig)
}
@@ -62,6 +65,10 @@ class ContactSearchViewModel(
configurationStore.update { it.copy(query = query) }
}
fun setConversationFilterRequest(conversationFilterRequest: ConversationFilterRequest) {
configurationStore.update { it.copy(conversationFilterRequest = conversationFilterRequest) }
}
fun expandSection(sectionKey: ContactSearchConfiguration.SectionKey) {
configurationStore.update { it.copy(expandedSections = it.expandedSections + sectionKey) }
}
@@ -141,10 +148,19 @@ class ContactSearchViewModel(
private val selectionLimits: SelectionLimits,
private val repository: ContactSearchRepository,
private val performSafetyNumberChecks: Boolean,
private val arbitraryRepository: ArbitraryRepository?
private val arbitraryRepository: ArbitraryRepository?,
private val searchRepository: SearchRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(ContactSearchViewModel(selectionLimits, repository, performSafetyNumberChecks, arbitraryRepository = arbitraryRepository)) as T
return modelClass.cast(
ContactSearchViewModel(
selectionLimits = selectionLimits,
contactSearchRepository = repository,
performSafetyNumberChecks = performSafetyNumberChecks,
arbitraryRepository = arbitraryRepository,
searchRepository = searchRepository
)
) as T
}
}
}

View File

@@ -120,7 +120,6 @@ class MultiselectForwardFragment :
contactSearchRecycler = view.findViewById(R.id.contact_selection_list)
contactSearchMediator = ContactSearchMediator(
this,
contactSearchRecycler,
FeatureFlags.shareSelectionLimit(),
!args.selectSingleRecipient,
ContactSearchAdapter.DisplaySmsTag.DEFAULT,
@@ -128,6 +127,8 @@ class MultiselectForwardFragment :
this::filterContacts
)
contactSearchRecycler.adapter = contactSearchMediator.adapter
callback = findListener()!!
disposables.bindTo(viewLifecycleOwner.lifecycle)

View File

@@ -32,6 +32,7 @@ import android.net.Uri;
import android.os.AsyncTask;
import android.os.Build;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
@@ -111,6 +112,11 @@ import org.thoughtcrime.securesms.components.settings.app.notifications.manual.N
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.UnexpectedSubscriptionCancellation;
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner;
import org.thoughtcrime.securesms.components.voice.VoiceNotePlayerView;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchMediator;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchState;
import org.thoughtcrime.securesms.contacts.sync.CdsPermanentErrorBottomSheet;
import org.thoughtcrime.securesms.contacts.sync.CdsTemporaryErrorBottomSheet;
import org.thoughtcrime.securesms.conversation.ConversationFragment;
@@ -128,6 +134,7 @@ import org.thoughtcrime.securesms.database.model.ThreadRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.events.ReminderUpdateEvent;
import org.thoughtcrime.securesms.exporter.flow.SmsExportDialogs;
import org.thoughtcrime.securesms.groups.SelectionLimits;
import org.thoughtcrime.securesms.insights.InsightsLauncher;
import org.thoughtcrime.securesms.jobs.ServiceOutageDetectionJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
@@ -149,7 +156,6 @@ import org.thoughtcrime.securesms.ratelimit.RecaptchaProofBottomSheetFragment;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.search.MessageResult;
import org.thoughtcrime.securesms.search.SearchResult;
import org.thoughtcrime.securesms.service.KeyCachingService;
import org.thoughtcrime.securesms.sms.MessageSender;
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
@@ -158,7 +164,6 @@ import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsViewModel;
import org.thoughtcrime.securesms.util.AppForegroundObserver;
import org.thoughtcrime.securesms.util.AppStartup;
import org.thoughtcrime.securesms.util.BottomSheetUtil;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.ConversationUtil;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.LifecycleDisposable;
@@ -167,11 +172,11 @@ import org.thoughtcrime.securesms.util.ServiceUtil;
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
import org.thoughtcrime.securesms.util.SignalProxyUtil;
import org.thoughtcrime.securesms.util.SnapToTopDataObserver;
import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.WindowUtil;
import org.thoughtcrime.securesms.util.adapter.mapping.PagingMappingAdapter;
import org.thoughtcrime.securesms.util.task.SnackbarAsyncTask;
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
import org.thoughtcrime.securesms.util.views.Stub;
@@ -185,20 +190,20 @@ import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import kotlin.Unit;
import static android.app.Activity.RESULT_CANCELED;
import static android.app.Activity.RESULT_OK;
public class ConversationListFragment extends MainFragment implements ActionMode.Callback,
ConversationListAdapter.OnConversationClickListener,
ConversationListSearchAdapter.EventListener,
MegaphoneActionController, ClearFilterViewHolder.OnClearFilterClickListener
{
public static final short MESSAGE_REQUESTS_REQUEST_CODE_CREATE_NAME = 32562;
@@ -221,9 +226,8 @@ public class ConversationListFragment extends MainFragment implements ActionMode
private AppBarLayout pullViewAppBarLayout;
private ConversationListViewModel viewModel;
private RecyclerView.Adapter activeAdapter;
private ConversationListAdapter defaultAdapter;
private ConversationListSearchAdapter searchAdapter;
private StickyHeaderDecoration searchAdapterDecoration;
private ConversationListAdapter defaultAdapter;
private PagingMappingAdapter<ContactSearchKey> searchAdapter;
private Stub<ViewGroup> megaphoneContainer;
private SnapToTopDataObserver snapToTopDataObserver;
private Drawable archiveDrawable;
@@ -239,6 +243,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
protected ConversationListItemAnimator itemAnimator;
private Stopwatch startupStopwatch;
private ConversationListTabsViewModel conversationListTabsViewModel;
private ContactSearchMediator contactSearchMediator;
public static ConversationListFragment newInstance() {
return new ConversationListFragment();
@@ -284,6 +289,49 @@ public class ConversationListFragment extends MainFragment implements ActionMode
fab.setVisibility(View.VISIBLE);
cameraFab.setVisibility(View.VISIBLE);
contactSearchMediator = new ContactSearchMediator(this,
SelectionLimits.NO_LIMITS,
false,
ContactSearchAdapter.DisplaySmsTag.DEFAULT,
this::mapSearchStateToConfiguration,
(v, s) -> s,
false,
(displayCheckBox,
displaySmsTag,
recipientListener,
storyListener,
storyContextMenuCallbacks,
expandListener
) -> {
//noinspection CodeBlock2Expr
return new ConversationListSearchAdapter(
displayCheckBox,
displaySmsTag,
recipientListener,
storyListener,
storyContextMenuCallbacks,
expandListener,
(v, t, b) -> {
onConversationClicked(t.getThreadRecord());
return Unit.INSTANCE;
},
(v, m, b) -> {
onMessageClicked(m.getMessageResult());
return Unit.INSTANCE;
},
getViewLifecycleOwner(),
GlideApp.with(this),
() -> {
onClearFilterClick();
return Unit.INSTANCE;
}
);
},
new ConversationListSearchAdapter.ChatFilterRepository()
);
searchAdapter = contactSearchMediator.getAdapter();
CollapsingToolbarLayout collapsingToolbarLayout = view.findViewById(R.id.collapsing_toolbar);
int openHeight = (int) DimensionUnit.DP.toPixels(FilterLerp.FILTER_OPEN_HEIGHT);
@@ -427,7 +475,6 @@ public class ConversationListFragment extends MainFragment implements ActionMode
}
if ((!requireCallback().getSearchToolbar().resolved() || !(requireCallback().getSearchToolbar().get().getVisibility() == View.VISIBLE)) && list.getAdapter() != defaultAdapter) {
list.removeItemDecoration(searchAdapterDecoration);
setAdapter(defaultAdapter);
}
@@ -549,6 +596,47 @@ public class ConversationListFragment extends MainFragment implements ActionMode
onMegaphoneChanged(viewModel.getMegaphone().getValue());
}
private ContactSearchConfiguration mapSearchStateToConfiguration(@NonNull ContactSearchState state) {
if (TextUtils.isEmpty(state.getQuery())) {
return ContactSearchConfiguration.build(b -> Unit.INSTANCE);
} else {
return ContactSearchConfiguration.build(b -> {
ConversationFilterRequest conversationFilterRequest = state.getConversationFilterRequest();
boolean unreadOnly = conversationFilterRequest != null && conversationFilterRequest.getFilter() == ConversationFilter.UNREAD;
b.setQuery(state.getQuery());
b.addSection(new ContactSearchConfiguration.Section.Chats(
unreadOnly,
true,
new ContactSearchConfiguration.ExpandConfig(
state.getExpandedSections().contains(ContactSearchConfiguration.SectionKey.CHATS),
(a) -> 7
)
));
if (!unreadOnly) {
// Groups-with-member-section
b.addSection(new ContactSearchConfiguration.Section.Messages(
true,
null
));
b.setHasEmptyState(true);
} else {
b.arbitrary(
conversationFilterRequest.getSource() == ConversationFilterSource.DRAG
? ConversationListSearchAdapter.ChatFilterOptions.WITHOUT_TIP.getCode()
: ConversationListSearchAdapter.ChatFilterOptions.WITH_TIP.getCode()
);
}
return Unit.INSTANCE;
});
}
}
private boolean isSearchOpen() {
return isSearchVisible() || activeAdapter == searchAdapter;
}
@@ -559,7 +647,6 @@ public class ConversationListFragment extends MainFragment implements ActionMode
private boolean closeSearchIfOpen() {
if (isSearchOpen()) {
list.removeItemDecoration(searchAdapterDecoration);
setAdapter(defaultAdapter);
requireCallback().getSearchToolbar().get().collapse();
requireCallback().onSearchClosed();
@@ -591,8 +678,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
}
}
@Override
public void onConversationClicked(@NonNull ThreadRecord threadRecord) {
private void onConversationClicked(@NonNull ThreadRecord threadRecord) {
hideKeyboard();
getNavigator().goToConversation(threadRecord.getRecipient().getId(),
threadRecord.getThreadId(),
@@ -608,8 +694,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
}
}
@Override
public void onContactClicked(@NonNull Recipient contact) {
private void onContactClicked(@NonNull Recipient contact) {
SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> {
return SignalDatabase.threads().getThreadIdIfExistsFor(contact.getId());
}, threadId -> {
@@ -621,8 +706,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
});
}
@Override
public void onMessageClicked(@NonNull MessageResult message) {
private void onMessageClicked(@NonNull MessageResult message) {
SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> {
int startingPosition = SignalDatabase.messages().getMessagePositionInConversation(message.getThreadId(), message.getReceivedTimestampMs());
return Math.max(0, startingPosition);
@@ -692,6 +776,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
private void initializeSearchListener() {
viewModel.getConversationFilterRequest().observe(getViewLifecycleOwner(), this::updateSearchToolbarHint);
viewModel.getConversationFilterRequest().observe(getViewLifecycleOwner(), contactSearchMediator::onConversationFilterRequestChanged);
requireCallback().getSearchAction().setOnClickListener(v -> {
fadeOutButtonsAndMegaphone(250);
@@ -702,18 +787,15 @@ public class ConversationListFragment extends MainFragment implements ActionMode
public void onSearchTextChange(String text) {
String trimmed = text.trim();
viewModel.onSearchQueryUpdated(trimmed);
contactSearchMediator.onFilterChanged(trimmed);
if (trimmed.length() > 0) {
if (activeAdapter != searchAdapter && list != null) {
setAdapter(searchAdapter);
list.removeItemDecoration(searchAdapterDecoration);
list.addItemDecoration(searchAdapterDecoration);
}
} else {
if (activeAdapter != defaultAdapter) {
if (list != null) {
list.removeItemDecoration(searchAdapterDecoration);
setAdapter(defaultAdapter);
}
}
@@ -723,7 +805,6 @@ public class ConversationListFragment extends MainFragment implements ActionMode
@Override
public void onSearchClosed() {
if (list != null) {
list.removeItemDecoration(searchAdapterDecoration);
setAdapter(defaultAdapter);
}
requireCallback().onSearchClosed();
@@ -763,8 +844,6 @@ public class ConversationListFragment extends MainFragment implements ActionMode
private void initializeListAdapters() {
defaultAdapter = new ConversationListAdapter(getViewLifecycleOwner(), GlideApp.with(this), this, this);
searchAdapter = new ConversationListSearchAdapter(getViewLifecycleOwner(), GlideApp.with(this), this, Locale.getDefault(), this);
searchAdapterDecoration = new StickyHeaderDecoration(searchAdapter, false, false, 0);
setAdapter(defaultAdapter);
@@ -827,12 +906,10 @@ public class ConversationListFragment extends MainFragment implements ActionMode
}
private void initializeViewModel() {
ConversationListViewModel.Factory viewModelFactory = new ConversationListViewModel.Factory(isArchived(),
getString(R.string.note_to_self));
ConversationListViewModel.Factory viewModelFactory = new ConversationListViewModel.Factory(isArchived());
viewModel = new ViewModelProvider(this, (ViewModelProvider.Factory) viewModelFactory).get(ConversationListViewModel.class);
viewModel.getSearchResult().observe(getViewLifecycleOwner(), this::onSearchResultChanged);
viewModel.getMegaphone().observe(getViewLifecycleOwner(), this::onMegaphoneChanged);
viewModel.getConversationList().observe(getViewLifecycleOwner(), this::onConversationListChanged);
viewModel.hasNoConversations().observe(getViewLifecycleOwner(), this::updateEmptyState);
@@ -896,11 +973,6 @@ public class ConversationListFragment extends MainFragment implements ActionMode
requireCallback().getUnreadPaymentsDot().animate().alpha(0);
}
private void onSearchResultChanged(@Nullable SearchResult result) {
result = result != null ? result : SearchResult.EMPTY;
searchAdapter.updateResults(result);
}
private void onMegaphoneChanged(@Nullable Megaphone megaphone) {
if (megaphone == null || isArchived() || getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) {
if (megaphoneContainer.resolved()) {

View File

@@ -1,302 +0,0 @@
package org.thoughtcrime.securesms.conversationlist;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.LifecycleOwner;
import androidx.recyclerview.widget.RecyclerView;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter;
import org.thoughtcrime.securesms.conversationlist.model.ConversationSet;
import org.thoughtcrime.securesms.database.model.ThreadRecord;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.search.MessageResult;
import org.thoughtcrime.securesms.search.SearchResult;
import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
import java.util.Collections;
import java.util.Locale;
class ConversationListSearchAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
implements StickyHeaderDecoration.StickyHeaderAdapter<ConversationListSearchAdapter.HeaderViewHolder>
{
private static final int VIEW_TYPE_EMPTY = 0;
private static final int VIEW_TYPE_NON_EMPTY = 1;
private static final int VIEW_TYPE_CHAT_FILTER = 2;
private static final int VIEW_TYPE_CHAT_FILTER_EMPTY = 3;
private static final int TYPE_CONVERSATIONS = 1;
private static final int TYPE_CONTACTS = 2;
private static final int TYPE_MESSAGES = 3;
private final LifecycleOwner lifecycleOwner;
private final GlideRequests glideRequests;
private final EventListener eventListener;
private final Locale locale;
private final ClearFilterViewHolder.OnClearFilterClickListener onClearFilterClicked;
@NonNull
private SearchResult searchResult = SearchResult.EMPTY;
ConversationListSearchAdapter(@NonNull LifecycleOwner lifecycleOwner,
@NonNull GlideRequests glideRequests,
@NonNull EventListener eventListener,
@NonNull Locale locale,
@NonNull ClearFilterViewHolder.OnClearFilterClickListener onClearFilterClicked)
{
this.lifecycleOwner = lifecycleOwner;
this.glideRequests = glideRequests;
this.eventListener = eventListener;
this.locale = locale;
this.onClearFilterClicked = onClearFilterClicked;
}
@Override
public @NonNull RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
if (viewType == VIEW_TYPE_EMPTY) {
return new EmptyViewHolder(LayoutInflater.from(parent.getContext())
.inflate(R.layout.conversation_list_empty_search_state, parent, false));
} else if (viewType == VIEW_TYPE_CHAT_FILTER) {
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.conversation_list_item_clear_filter, parent, false);
return new ClearFilterViewHolder(v, onClearFilterClicked);
} else if (viewType == VIEW_TYPE_CHAT_FILTER_EMPTY) {
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.conversation_list_item_clear_filter_empty, parent, false);
return new ClearFilterViewHolder(v, onClearFilterClicked);
} else {
return new SearchResultViewHolder(LayoutInflater.from(parent.getContext())
.inflate(R.layout.conversation_list_item_view, parent, false));
}
}
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
if (holder instanceof SearchResultViewHolder) {
SearchResultViewHolder viewHolder = (SearchResultViewHolder) holder;
ThreadRecord conversationResult = getConversationResult(position);
if (conversationResult != null) {
viewHolder.bind(lifecycleOwner, conversationResult, glideRequests, eventListener, locale, searchResult.getQuery());
return;
}
Recipient contactResult = getContactResult(position);
if (contactResult != null) {
viewHolder.bind(lifecycleOwner, contactResult, glideRequests, eventListener, locale, searchResult.getQuery());
return;
}
MessageResult messageResult = getMessageResult(position);
if (messageResult != null) {
viewHolder.bind(lifecycleOwner, messageResult, glideRequests, eventListener, locale, searchResult.getQuery());
}
} else if (holder instanceof EmptyViewHolder) {
EmptyViewHolder viewHolder = (EmptyViewHolder) holder;
viewHolder.bind(searchResult.getQuery());
}
}
@Override
public int getItemViewType(int position) {
if (searchResult.isEmpty() && searchResult.getConversationFilter() == ConversationFilter.OFF) {
return VIEW_TYPE_EMPTY;
} else if (searchResult.isEmpty()) {
return VIEW_TYPE_CHAT_FILTER_EMPTY;
} else if (position == getChatFilterIndex()) {
return VIEW_TYPE_CHAT_FILTER;
} else {
return VIEW_TYPE_NON_EMPTY;
}
}
@Override
public void onViewRecycled(@NonNull RecyclerView.ViewHolder holder) {
if (holder instanceof SearchResultViewHolder) {
((SearchResultViewHolder) holder).recycle();
}
}
@Override
public int getItemCount() {
if (searchResult.isEmpty()) {
return 1;
} if (searchResult.getConversationFilter() != ConversationFilter.OFF) {
return searchResult.size() + 1;
} else {
return searchResult.size();
}
}
@Override
public long getHeaderId(int position) {
if (position < 0 || searchResult.isEmpty() || position == getChatFilterIndex()) {
return StickyHeaderDecoration.StickyHeaderAdapter.NO_HEADER_ID;
} else if (getConversationResult(position) != null) {
return TYPE_CONVERSATIONS;
} else if (getContactResult(position) != null) {
return TYPE_CONTACTS;
} else {
return TYPE_MESSAGES;
}
}
@Override
public HeaderViewHolder onCreateHeaderViewHolder(ViewGroup parent, int position, int type) {
return new HeaderViewHolder(LayoutInflater.from(parent.getContext())
.inflate(R.layout.dsl_section_header, parent, false));
}
@Override
public void onBindHeaderViewHolder(HeaderViewHolder viewHolder, int position, int type) {
viewHolder.bind((int) getHeaderId(position));
}
void updateResults(@NonNull SearchResult result) {
this.searchResult = result;
notifyDataSetChanged();
}
@Nullable
private ThreadRecord getConversationResult(int position) {
if (position < searchResult.getConversations().size()) {
return searchResult.getConversations().get(position);
}
return null;
}
@Nullable
private Recipient getContactResult(int position) {
if (position >= getFirstContactIndex() && position < getFirstMessageIndex()) {
return searchResult.getContacts().get(position - getFirstContactIndex());
}
return null;
}
@Nullable
private MessageResult getMessageResult(int position) {
if (position >= getFirstMessageIndex() && position < searchResult.size()) {
return searchResult.getMessages().get(position - getFirstMessageIndex());
}
return null;
}
private int getFirstContactIndex() {
return searchResult.getConversations().size();
}
private int getFirstMessageIndex() {
return getFirstContactIndex() + searchResult.getContacts().size();
}
private int getChatFilterIndex() {
if (searchResult.getConversationFilter() == ConversationFilter.OFF) {
return -1;
}
if (searchResult.isEmpty()) {
return 0;
}
return searchResult.size();
}
public interface EventListener {
void onConversationClicked(@NonNull ThreadRecord threadRecord);
void onContactClicked(@NonNull Recipient contact);
void onMessageClicked(@NonNull MessageResult message);
}
static class EmptyViewHolder extends RecyclerView.ViewHolder {
private final TextView textView;
public EmptyViewHolder(@NonNull View itemView) {
super(itemView);
textView = itemView.findViewById(R.id.search_no_results);
}
public void bind(@NonNull String query) {
textView.setText(textView.getContext().getString(R.string.SearchFragment_no_results, query));
}
}
static class SearchResultViewHolder extends RecyclerView.ViewHolder {
private final ConversationListItem root;
SearchResultViewHolder(View itemView) {
super(itemView);
root = (ConversationListItem) itemView;
}
void bind(@NonNull LifecycleOwner lifecycleOwner,
@NonNull ThreadRecord conversationResult,
@NonNull GlideRequests glideRequests,
@NonNull EventListener eventListener,
@NonNull Locale locale,
@Nullable String query)
{
root.bindThread(lifecycleOwner, conversationResult, glideRequests, locale, Collections.emptySet(), new ConversationSet(), query);
root.setOnClickListener(view -> eventListener.onConversationClicked(conversationResult));
}
void bind(@NonNull LifecycleOwner lifecycleOwner,
@NonNull Recipient contactResult,
@NonNull GlideRequests glideRequests,
@NonNull EventListener eventListener,
@NonNull Locale locale,
@Nullable String query)
{
root.bindContact(lifecycleOwner, contactResult, glideRequests, locale, query);
root.setOnClickListener(view -> eventListener.onContactClicked(contactResult));
}
void bind(@NonNull LifecycleOwner lifecycleOwner,
@NonNull MessageResult messageResult,
@NonNull GlideRequests glideRequests,
@NonNull EventListener eventListener,
@NonNull Locale locale,
@Nullable String query)
{
root.bindMessage(lifecycleOwner, messageResult, glideRequests, locale, query);
root.setOnClickListener(view -> eventListener.onMessageClicked(messageResult));
}
void recycle() {
root.unbind();
root.setOnClickListener(null);
}
}
public static class HeaderViewHolder extends RecyclerView.ViewHolder {
private TextView titleView;
public HeaderViewHolder(View itemView) {
super(itemView);
titleView = itemView.findViewById(R.id.section_header);
}
public void bind(int headerType) {
switch (headerType) {
case TYPE_CONVERSATIONS:
titleView.setText(R.string.SearchFragment_header_conversations);
break;
case TYPE_CONTACTS:
titleView.setText(R.string.SearchFragment_header_contacts);
break;
case TYPE_MESSAGES:
titleView.setText(R.string.SearchFragment_header_messages);
break;
}
}
}
}

View File

@@ -0,0 +1,175 @@
package org.thoughtcrime.securesms.conversationlist
import android.view.View
import android.widget.TextView
import androidx.core.os.bundleOf
import androidx.lifecycle.LifecycleOwner
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contacts.paged.ArbitraryRepository
import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
import org.thoughtcrime.securesms.contacts.paged.ContactSearchData
import org.thoughtcrime.securesms.conversationlist.model.ConversationSet
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
import org.thoughtcrime.securesms.util.visible
import java.util.Locale
/**
* Adapter for ConversationList search. Adds factories to render ThreadModel and MessageModel using ConversationListItem,
* as well as ChatFilter row support and empty state handler.
*/
class ConversationListSearchAdapter(
displayCheckBox: Boolean,
displaySmsTag: DisplaySmsTag,
recipientListener: (View, ContactSearchData.KnownRecipient, Boolean) -> Unit,
storyListener: (View, ContactSearchData.Story, Boolean) -> Unit,
storyContextMenuCallbacks: StoryContextMenuCallbacks,
expandListener: (ContactSearchData.Expand) -> Unit,
threadListener: (View, ContactSearchData.Thread, Boolean) -> Unit,
messageListener: (View, ContactSearchData.Message, Boolean) -> Unit,
lifecycleOwner: LifecycleOwner,
glideRequests: GlideRequests,
clearFilterListener: () -> Unit
) : ContactSearchAdapter(displayCheckBox, displaySmsTag, recipientListener, storyListener, storyContextMenuCallbacks, expandListener) {
init {
registerFactory(
ThreadModel::class.java,
LayoutFactory({ ThreadViewHolder(threadListener, lifecycleOwner, glideRequests, it) }, R.layout.conversation_list_item_view)
)
registerFactory(
MessageModel::class.java,
LayoutFactory({ MessageViewHolder(messageListener, lifecycleOwner, glideRequests, it) }, R.layout.conversation_list_item_view)
)
registerFactory(
ChatFilterMappingModel::class.java,
LayoutFactory({ ChatFilterViewHolder(it, clearFilterListener) }, R.layout.conversation_list_item_clear_filter)
)
registerFactory(
ChatFilterEmptyMappingModel::class.java,
LayoutFactory({ ChatFilterViewHolder(it, clearFilterListener) }, R.layout.conversation_list_item_clear_filter_empty)
)
registerFactory(
EmptyModel::class.java,
LayoutFactory({ EmptyViewHolder(it) }, R.layout.conversation_list_empty_search_state)
)
}
private class EmptyViewHolder(
itemView: View
) : MappingViewHolder<EmptyModel>(itemView) {
private val noResults = itemView.findViewById<TextView>(R.id.search_no_results)
override fun bind(model: EmptyModel) {
println("BIND")
noResults.text = context.getString(R.string.SearchFragment_no_results, model.empty.query ?: "")
}
}
private class ThreadViewHolder(
private val threadListener: (View, ContactSearchData.Thread, Boolean) -> Unit,
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)
}
(itemView as ConversationListItem).bindThread(
lifecycleOwner,
model.thread.threadRecord,
glideRequests,
Locale.getDefault(),
emptySet(),
ConversationSet(),
model.thread.query
)
}
}
private class MessageViewHolder(
private val messageListener: (View, ContactSearchData.Message, Boolean) -> Unit,
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)
}
(itemView as ConversationListItem).bindMessage(
lifecycleOwner,
model.message.messageResult,
glideRequests,
Locale.getDefault(),
model.message.query
)
}
}
private open class BaseChatFilterMappingModel<T : BaseChatFilterMappingModel<T>>(val options: ChatFilterOptions) : MappingModel<T> {
override fun areItemsTheSame(newItem: T): Boolean = true
override fun areContentsTheSame(newItem: T): Boolean = options == newItem.options
}
private class ChatFilterMappingModel(options: ChatFilterOptions) : BaseChatFilterMappingModel<ChatFilterMappingModel>(options)
private class ChatFilterEmptyMappingModel(options: ChatFilterOptions) : BaseChatFilterMappingModel<ChatFilterEmptyMappingModel>(options)
private class ChatFilterViewHolder<T : BaseChatFilterMappingModel<T>>(itemView: View, listener: () -> Unit) : MappingViewHolder<T>(itemView) {
private val tip = itemView.findViewById<View>(R.id.clear_filter_tip)
init {
itemView.findViewById<View>(R.id.clear_filter).setOnClickListener { listener() }
}
override fun bind(model: T) {
tip.visible = model.options == ChatFilterOptions.WITH_TIP
}
}
enum class ChatFilterOptions(val code: String) {
WITH_TIP("with-tip"),
WITHOUT_TIP("without-tip");
companion object {
fun fromCode(code: String): ChatFilterOptions {
return values().firstOrNull { it.code == code } ?: WITHOUT_TIP
}
}
}
class ChatFilterRepository : ArbitraryRepository {
override fun getSize(section: ContactSearchConfiguration.Section.Arbitrary, query: String?): Int = section.types.size
override fun getData(
section: ContactSearchConfiguration.Section.Arbitrary,
query: String?,
startIndex: Int,
endIndex: Int,
totalSearchSize: Int
): List<ContactSearchData.Arbitrary> {
return section.types.map {
ContactSearchData.Arbitrary(it, bundleOf("total-size" to totalSearchSize))
}
}
override fun getMappingModel(arbitrary: ContactSearchData.Arbitrary): MappingModel<*> {
val options = ChatFilterOptions.fromCode(arbitrary.type)
val totalSearchSize = arbitrary.data?.getInt("total-size", -1) ?: -1
return if (totalSearchSize == 1) {
ChatFilterEmptyMappingModel(options)
} else {
ChatFilterMappingModel(options)
}
}
}
}

View File

@@ -1,7 +1,5 @@
package org.thoughtcrime.securesms.conversationlist;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.LiveDataReactiveStreams;
@@ -34,7 +32,6 @@ import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile;
import org.thoughtcrime.securesms.payments.UnreadPaymentsRepository;
import org.thoughtcrime.securesms.search.SearchRepository;
import org.thoughtcrime.securesms.search.SearchResult;
import org.thoughtcrime.securesms.util.Debouncer;
import org.thoughtcrime.securesms.util.ThrottledDebouncer;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
import org.thoughtcrime.securesms.util.paging.Invalidator;
@@ -44,7 +41,6 @@ import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.TimeUnit;
@@ -64,17 +60,13 @@ class ConversationListViewModel extends ViewModel {
private static boolean coldStart = true;
private final MutableLiveData<Megaphone> megaphone;
private final MutableLiveData<SearchResult> searchResult;
private final MutableLiveData<ConversationSet> selectedConversations;
private final MutableLiveData<ConversationFilterRequest> conversationFilterRequest;
private final LiveData<ConversationListDataSource> conversationListDataSource;
private final Set<Conversation> internalSelection;
private final LiveData<LivePagedData<Long, Conversation>> pagedData;
private final LiveData<Boolean> hasNoConversations;
private final SearchRepository searchRepository;
private final MegaphoneRepository megaphoneRepository;
private final Debouncer messageSearchDebouncer;
private final Debouncer contactSearchDebouncer;
private final ThrottledDebouncer updateDebouncer;
private final DatabaseObserver.Observer observer;
private final Invalidator invalidator;
@@ -82,24 +74,16 @@ class ConversationListViewModel extends ViewModel {
private final UnreadPaymentsLiveData unreadPaymentsLiveData;
private final UnreadPaymentsRepository unreadPaymentsRepository;
private final NotificationProfilesRepository notificationProfilesRepository;
private String activeQuery;
private SearchResult activeSearchResult;
private int pinnedCount;
private ConversationListViewModel(@NonNull SearchRepository searchRepository, boolean isArchived) {
private ConversationListViewModel(boolean isArchived) {
this.megaphone = new MutableLiveData<>();
this.searchResult = new MutableLiveData<>();
this.internalSelection = new HashSet<>();
this.selectedConversations = new MutableLiveData<>(new ConversationSet());
this.searchRepository = searchRepository;
this.megaphoneRepository = ApplicationDependencies.getMegaphoneRepository();
this.unreadPaymentsRepository = new UnreadPaymentsRepository();
this.notificationProfilesRepository = new NotificationProfilesRepository();
this.messageSearchDebouncer = new Debouncer(500);
this.contactSearchDebouncer = new Debouncer(100);
this.updateDebouncer = new ThrottledDebouncer(500);
this.activeSearchResult = SearchResult.EMPTY;
this.invalidator = new Invalidator();
this.disposables = new CompositeDisposable();
this.conversationFilterRequest = new MutableLiveData<>(new ConversationFilterRequest(ConversationFilter.OFF, ConversationFilterSource.DRAG));
@@ -115,10 +99,6 @@ class ConversationListViewModel extends ViewModel {
this.unreadPaymentsLiveData = new UnreadPaymentsLiveData();
this.observer = () -> {
updateDebouncer.publish(() -> {
if (!TextUtils.isEmpty(activeQuery)) {
onSearchQueryUpdated(activeQuery);
}
LivePagedData<Long, Conversation> data = pagedData.getValue();
if (data == null) {
return;
@@ -145,10 +125,6 @@ class ConversationListViewModel extends ViewModel {
return hasNoConversations;
}
@NonNull LiveData<SearchResult> getSearchResult() {
return searchResult;
}
@NonNull LiveData<Megaphone> getMegaphone() {
return megaphone;
}
@@ -223,14 +199,8 @@ class ConversationListViewModel extends ViewModel {
void setFiltered(boolean isFiltered, @NonNull ConversationFilterSource conversationFilterSource) {
if (isFiltered) {
conversationFilterRequest.setValue(new ConversationFilterRequest(ConversationFilter.UNREAD, conversationFilterSource));
if (activeQuery != null) {
onSearchQueryUpdated(activeQuery);
}
} else {
conversationFilterRequest.setValue(new ConversationFilterRequest(ConversationFilter.OFF, conversationFilterSource));
if (activeQuery != null) {
onSearchQueryUpdated(activeQuery);
}
}
}
@@ -272,68 +242,10 @@ class ConversationListViewModel extends ViewModel {
unreadPaymentsRepository.markAllPaymentsSeen();
}
void onSearchQueryUpdated(String query) {
activeQuery = query;
ConversationFilter filter = Objects.requireNonNull(conversationFilterRequest.getValue()).getFilter();
if (filter != ConversationFilter.OFF) {
contactSearchDebouncer.publish(() -> submitConversationSearch(query, filter));
return;
}
contactSearchDebouncer.publish(() -> {
submitConversationSearch(query, filter);
searchRepository.queryContacts(query, result -> {
if (!result.getQuery().equals(activeQuery)) {
return;
}
if (!activeSearchResult.getQuery().equals(activeQuery)) {
activeSearchResult = SearchResult.EMPTY;
}
activeSearchResult = activeSearchResult.merge(result);
searchResult.postValue(activeSearchResult);
});
});
messageSearchDebouncer.publish(() -> {
searchRepository.queryMessages(query, result -> {
if (!result.getQuery().equals(activeQuery)) {
return;
}
if (!activeSearchResult.getQuery().equals(activeQuery)) {
activeSearchResult = SearchResult.EMPTY;
}
activeSearchResult = activeSearchResult.merge(result);
searchResult.postValue(activeSearchResult);
});
});
}
private void submitConversationSearch(@NonNull String query, @NonNull ConversationFilter conversationFilter) {
searchRepository.queryThreads(query, conversationFilter == ConversationFilter.UNREAD, result -> {
if (!result.getQuery().equals(activeQuery)) {
return;
}
if (!activeSearchResult.getQuery().equals(activeQuery)) {
activeSearchResult = SearchResult.EMPTY;
}
activeSearchResult = activeSearchResult.merge(result).merge(conversationFilter);
searchResult.postValue(activeSearchResult);
});
}
@Override
protected void onCleared() {
invalidator.invalidate();
disposables.dispose();
messageSearchDebouncer.clear();
updateDebouncer.clear();
ApplicationDependencies.getDatabaseObserver().unregisterObserver(observer);
}
@@ -341,17 +253,15 @@ class ConversationListViewModel extends ViewModel {
public static class Factory extends ViewModelProvider.NewInstanceFactory {
private final boolean isArchived;
private final String noteToSelfTitle;
public Factory(boolean isArchived, @NonNull String noteToSelfTitle) {
public Factory(boolean isArchived) {
this.isArchived = isArchived;
this.noteToSelfTitle = noteToSelfTitle;
}
@Override
public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
//noinspection ConstantConditions
return modelClass.cast(new ConversationListViewModel(new SearchRepository(noteToSelfTitle), isArchived));
return modelClass.cast(new ConversationListViewModel(isArchived));
}
}
}

View File

@@ -64,7 +64,6 @@ class ChooseGroupStoryBottomSheet : FixedRoundedCornerBottomSheetDialogFragment(
val contactRecycler: RecyclerView = view.findViewById(R.id.contact_recycler)
mediator = ContactSearchMediator(
fragment = this,
recyclerView = contactRecycler,
selectionLimits = FeatureFlags.shareSelectionLimit(),
displayCheckBox = true,
displaySmsTag = ContactSearchAdapter.DisplaySmsTag.DEFAULT,
@@ -84,6 +83,8 @@ class ChooseGroupStoryBottomSheet : FixedRoundedCornerBottomSheetDialogFragment(
performSafetyNumberChecks = false
)
contactRecycler.adapter = mediator.adapter
mediator.getSelectionState().observe(viewLifecycleOwner) { state ->
adapter.submitList(
state.filterIsInstance(ContactSearchKey.RecipientSearchKey::class.java)

View File

@@ -7,6 +7,7 @@ import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import com.annimon.stream.Stream;
@@ -80,15 +81,14 @@ public class SearchRepository {
this.serialExecutor = new SerialExecutor(SignalExecutors.BOUNDED);
}
public void queryThreads(@NonNull String query, boolean unreadOnly, @NonNull Consumer<ThreadSearchResult> callback) {
searchExecutor.execute(2, () -> {
long start = System.currentTimeMillis();
List<ThreadRecord> result = queryConversations(query, unreadOnly);
@WorkerThread
public @NonNull ThreadSearchResult queryThreadsSync(@NonNull String query, boolean unreadOnly) {
long start = System.currentTimeMillis();
List<ThreadRecord> result = queryConversations(query, unreadOnly);
Log.d(TAG, "[threads] Search took " + (System.currentTimeMillis() - start) + " ms");
Log.d(TAG, "[threads] Search took " + (System.currentTimeMillis() - start) + " ms");
callback.accept(new ThreadSearchResult(result, query));
});
return new ThreadSearchResult(result, query);
}
public void queryContacts(@NonNull String query, @NonNull Consumer<ContactSearchResult> callback) {
@@ -102,19 +102,18 @@ public class SearchRepository {
});
}
public void queryMessages(@NonNull String query, @NonNull Consumer<MessageSearchResult> callback) {
searchExecutor.execute(0, () -> {
long start = System.currentTimeMillis();
String cleanQuery = FtsUtil.sanitize(query);
@WorkerThread
public @NonNull MessageSearchResult queryMessagesSync(@NonNull String query) {
long start = System.currentTimeMillis();
String cleanQuery = FtsUtil.sanitize(query);
List<MessageResult> messages = queryMessages(cleanQuery);
List<MessageResult> mentionMessages = queryMentions(sanitizeQueryAsTokens(query));
List<MessageResult> combined = mergeMessagesAndMentions(messages, mentionMessages);
List<MessageResult> messages = queryMessages(cleanQuery);
List<MessageResult> mentionMessages = queryMentions(sanitizeQueryAsTokens(query));
List<MessageResult> combined = mergeMessagesAndMentions(messages, mentionMessages);
Log.d(TAG, "[messages] Search took " + (System.currentTimeMillis() - start) + " ms");
Log.d(TAG, "[messages] Search took " + (System.currentTimeMillis() - start) + " ms");
callback.accept(new MessageSearchResult(combined, query));
});
return new MessageSearchResult(combined, query);
}
public void query(@NonNull String query, long threadId, @NonNull Callback<List<MessageResult>> callback) {
@@ -190,13 +189,13 @@ public class SearchRepository {
}
}
List<ThreadRecord> output = new ArrayList<>(contactIds.size() + groupsByTitleIds.size() + groupsByMemberIds.size());
LinkedHashSet<ThreadRecord> output = new LinkedHashSet<>();
output.addAll(getMatchingThreads(contactIds, unreadOnly));
output.addAll(getMatchingThreads(groupsByTitleIds, unreadOnly));
output.addAll(getMatchingThreads(groupsByMemberIds, unreadOnly));
return output;
return new ArrayList<>(output);
}
private List<ThreadRecord> getMatchingThreads(@NonNull Collection<RecipientId> recipientIds, boolean unreadOnly) {

View File

@@ -24,15 +24,16 @@ class ViewAllSignalConnectionsFragment : Fragment(R.layout.view_all_signal_conne
requireActivity().onBackPressedDispatcher.onBackPressed()
}
ContactSearchMediator(
val mediator = ContactSearchMediator(
fragment = this,
recyclerView = binding.recycler,
selectionLimits = SelectionLimits(0, 0),
displayCheckBox = false,
displaySmsTag = ContactSearchAdapter.DisplaySmsTag.IF_NOT_REGISTERED,
mapStateToConfiguration = { getConfiguration() },
performSafetyNumberChecks = false
)
binding.recycler.adapter = mediator.adapter
}
private fun getConfiguration(): ContactSearchConfiguration {

View File

@@ -233,6 +233,10 @@
<string name="ContactsCursorLoader_my_stories">My Stories</string>
<!-- Text for a button that brings up a bottom sheet to create a new story. -->
<string name="ContactsCursorLoader_new">New</string>
<!-- Header for conversation search section labeled "Chats" -->
<string name="ContactsCursorLoader__chats">Chats</string>
<!-- Header for conversation search section labeled "Messages" -->
<string name="ContactsCursorLoader__messages">Messages</string>
<!-- ContactsDatabase -->
<string name="ContactsDatabase_message_s">Message %s</string>

View File

@@ -206,7 +206,7 @@ class ContactSearchPagedDataSourceTest {
private class ArbitraryRepoFake : ArbitraryRepository {
override fun getSize(section: ContactSearchConfiguration.Section.Arbitrary, query: String?): Int = section.types.size
override fun getData(section: ContactSearchConfiguration.Section.Arbitrary, query: String?, startIndex: Int, endIndex: Int): List<ContactSearchData.Arbitrary> {
override fun getData(section: ContactSearchConfiguration.Section.Arbitrary, query: String?, startIndex: Int, endIndex: Int, totalSearchSize: Int): List<ContactSearchData.Arbitrary> {
return section.types.toList().slice(startIndex..endIndex).map {
ContactSearchData.Arbitrary(it, bundleOf("n" to it))
}