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
}
}
}