mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-24 21:15:48 +00:00
Add support for message and thread results.
This commit is contained in:
committed by
Greyson Parrelli
parent
8dd1d3bdeb
commit
b4a34599d7
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user