mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-13 12:07:57 +01:00
Allow conversation search results to come in piecemeal.
This commit is contained in:
committed by
Cody Henthorne
parent
8d42ce3b00
commit
265331f3e2
@@ -26,6 +26,7 @@ open class ContactSearchAdapter(
|
||||
ContactSearchModels.registerStoryItems(this, displayOptions.displayCheckBox, onClickCallbacks::onStoryClicked, storyContextMenuCallbacks, displayOptions.displayStoryRing)
|
||||
ContactSearchModels.registerKnownRecipientItems(this, fixedContacts, displayOptions, onClickCallbacks::onKnownRecipientClicked, longClickCallbacks::onKnownRecipientLongClick, callButtonClickCallbacks)
|
||||
ContactSearchModels.registerHeaders(this)
|
||||
ContactSearchModels.registerSectionLoading(this)
|
||||
ContactSearchModels.registerExpands(this, onClickCallbacks::onExpandClicked)
|
||||
ContactSearchModels.registerChatTypeItems(this, onClickCallbacks::onChatTypeClicked)
|
||||
ContactSearchModels.registerUnknownRecipientItems(this, onClickCallbacks::onUnknownRecipientClicked, displayOptions.displayCheckBox)
|
||||
|
||||
@@ -86,6 +86,12 @@ sealed class ContactSearchData(val contactSearchKey: ContactSearchKey) {
|
||||
*/
|
||||
class Expand(val sectionKey: ContactSearchConfiguration.SectionKey) : ContactSearchData(ContactSearchKey.Expand(sectionKey))
|
||||
|
||||
/**
|
||||
* A row indicating that the section is still being queried. Sections are queried in parallel and
|
||||
* rendered as they finish, so this stands in for a section whose results have not arrived yet.
|
||||
*/
|
||||
data class SectionLoading(val sectionKey: ContactSearchConfiguration.SectionKey) : ContactSearchData(ContactSearchKey.SectionLoading(sectionKey))
|
||||
|
||||
/**
|
||||
* A row representing arbitrary data tied to a specific section.
|
||||
*/
|
||||
|
||||
@@ -54,6 +54,11 @@ sealed class ContactSearchKey {
|
||||
*/
|
||||
data class Expand(val sectionKey: ContactSearchConfiguration.SectionKey) : ContactSearchKey()
|
||||
|
||||
/**
|
||||
* Key to the loading placeholder shown while a given section is still being queried
|
||||
*/
|
||||
data class SectionLoading(val sectionKey: ContactSearchConfiguration.SectionKey) : ContactSearchKey()
|
||||
|
||||
/**
|
||||
* Arbitrary takes a string type and will map to exactly one ArbitraryData object.
|
||||
*
|
||||
|
||||
@@ -15,16 +15,22 @@ import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.widget.AppCompatImageView
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -51,6 +57,7 @@ import org.thoughtcrime.securesms.components.AvatarImageView
|
||||
import org.thoughtcrime.securesms.components.FromTextView
|
||||
import org.thoughtcrime.securesms.components.menu.ActionItem
|
||||
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
|
||||
import org.thoughtcrime.securesms.components.settings.models.DSLComposePreference
|
||||
import org.thoughtcrime.securesms.contacts.LetterHeaderDecoration
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode
|
||||
import org.thoughtcrime.securesms.database.model.StoryViewState
|
||||
@@ -123,6 +130,10 @@ object ContactSearchModels {
|
||||
)
|
||||
}
|
||||
|
||||
fun registerSectionLoading(mappingAdapter: MappingAdapter) {
|
||||
DSLComposePreference.register<SectionLoadingModel>(mappingAdapter) { SectionLoadingViewHolder(it) }
|
||||
}
|
||||
|
||||
fun registerExpands(mappingAdapter: MappingAdapter, expandListener: (ContactSearchData.Expand) -> Unit) {
|
||||
mappingAdapter.registerFactory(
|
||||
ExpandModel::class.java,
|
||||
@@ -214,6 +225,11 @@ object ContactSearchModels {
|
||||
R.layout.contact_search_section_header
|
||||
).createViewHolder(FrameLayout(ctx))
|
||||
}
|
||||
entry<SectionLoadingModel>(
|
||||
key = { model -> "SECTION_LOADING${model.sectionLoading.sectionKey}" }
|
||||
) {
|
||||
SectionLoadingRow()
|
||||
}
|
||||
viewHolder<ExpandModel>(
|
||||
key = { model -> "EXPAND${model.expand.sectionKey}" }
|
||||
) { ctx ->
|
||||
@@ -252,6 +268,7 @@ object ContactSearchModels {
|
||||
is ContactSearchData.Story -> StoryModel(it, selection.contains(it.contactSearchKey), SignalStore.story.userHasBeenNotifiedAboutStories)
|
||||
is ContactSearchData.KnownRecipient -> RecipientModel(it, selection.contains(it.contactSearchKey), it.shortSummary)
|
||||
is ContactSearchData.Expand -> ExpandModel(it)
|
||||
is ContactSearchData.SectionLoading -> SectionLoadingModel(it)
|
||||
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")
|
||||
@@ -788,6 +805,19 @@ object ContactSearchModels {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapping Model for the placeholder shown in place of a section whose query is still running.
|
||||
*/
|
||||
class SectionLoadingModel(val sectionLoading: ContactSearchData.SectionLoading) : MappingModel<SectionLoadingModel> {
|
||||
override fun areItemsTheSame(newItem: SectionLoadingModel): Boolean {
|
||||
return sectionLoading.sectionKey == newItem.sectionLoading.sectionKey
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: SectionLoadingModel): Boolean {
|
||||
return areItemsTheSame(newItem)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapping Model for messages
|
||||
*/
|
||||
@@ -823,6 +853,31 @@ object ContactSearchModels {
|
||||
override fun areItemsTheSame(newItem: GroupWithMembersModel): Boolean = newItem.groupWithMembers.contactSearchKey == groupWithMembers.contactSearchKey
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionLoadingRow() {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp)
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
strokeWidth = 3.dp,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* View Holder for the placeholder shown in place of a section whose query is still running.
|
||||
*/
|
||||
private class SectionLoadingViewHolder(composeView: ComposeView) : DSLComposePreference.ViewHolder<SectionLoadingModel>(composeView) {
|
||||
@Composable
|
||||
override fun Content(model: SectionLoadingModel) {
|
||||
SectionLoadingRow()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* View Holder for section headers
|
||||
*/
|
||||
|
||||
+43
-14
@@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.contacts.paged
|
||||
|
||||
import android.database.Cursor
|
||||
import androidx.annotation.WorkerThread
|
||||
import org.signal.core.util.requireLong
|
||||
import org.signal.paging.PagedDataSource
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.contacts.ContactRepository
|
||||
@@ -10,7 +9,6 @@ import org.thoughtcrime.securesms.contacts.paged.collections.ContactSearchCollec
|
||||
import org.thoughtcrime.securesms.contacts.paged.collections.ContactSearchIterator
|
||||
import org.thoughtcrime.securesms.contacts.paged.collections.CursorSearchIterator
|
||||
import org.thoughtcrime.securesms.contacts.paged.collections.StoriesSearchCollection
|
||||
import org.thoughtcrime.securesms.database.GroupTable
|
||||
import org.thoughtcrime.securesms.database.RecipientTable
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode
|
||||
import org.thoughtcrime.securesms.database.model.GroupRecord
|
||||
@@ -19,6 +17,7 @@ import org.thoughtcrime.securesms.groups.GroupsInCommonSummary
|
||||
import org.thoughtcrime.securesms.keyvalue.StorySend
|
||||
import org.thoughtcrime.securesms.phonenumbers.NumberUtil
|
||||
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
|
||||
@@ -33,7 +32,8 @@ class ContactSearchPagedDataSource(
|
||||
private val contactConfiguration: ContactSearchConfiguration,
|
||||
private val contactSearchPagedDataSourceRepository: ContactSearchPagedDataSourceRepository,
|
||||
private val arbitraryRepository: ArbitraryRepository? = null,
|
||||
private val searchRepository: SearchRepository? = null
|
||||
private val searchRepository: SearchRepository? = null,
|
||||
private val sectionResults: ContactSearchSectionResults = ContactSearchSectionResults()
|
||||
) : PagedDataSource<ContactSearchKey, ContactSearchData> {
|
||||
|
||||
companion object {
|
||||
@@ -130,6 +130,10 @@ class ContactSearchPagedDataSource(
|
||||
}
|
||||
|
||||
private fun getSectionSize(section: ContactSearchConfiguration.Section, query: String?): Int {
|
||||
if (section.sectionKey in sectionResults.pending) {
|
||||
return getPendingSectionRows(section).size
|
||||
}
|
||||
|
||||
return when (section) {
|
||||
is ContactSearchConfiguration.Section.Individuals -> getNonGroupSearchIterator(section, query).getCollectionSizeAndClose(section, query, null)
|
||||
is ContactSearchConfiguration.Section.Groups -> contactSearchPagedDataSourceRepository.getGroupSearchIterator(section, query).getCollectionSizeAndClose(section, query, this::canSendToGroup)
|
||||
@@ -172,6 +176,12 @@ class ContactSearchPagedDataSource(
|
||||
|
||||
@WorkerThread
|
||||
private fun getSectionData(section: ContactSearchConfiguration.Section, query: String?, startIndex: Int, endIndex: Int): List<ContactSearchData> {
|
||||
if (section.sectionKey in sectionResults.pending) {
|
||||
val rows = getPendingSectionRows(section)
|
||||
val start = startIndex.coerceIn(0, rows.size)
|
||||
return rows.subList(start, endIndex.coerceIn(start, rows.size))
|
||||
}
|
||||
|
||||
return when (section) {
|
||||
is ContactSearchConfiguration.Section.Groups -> getGroupContactsData(section, query, startIndex, endIndex)
|
||||
is ContactSearchConfiguration.Section.Individuals -> getNonGroupContactsData(section, query, startIndex, endIndex)
|
||||
@@ -190,6 +200,20 @@ class ContactSearchPagedDataSource(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows a section occupies while its query is still running: its header, so the user can see
|
||||
* which section is outstanding, plus a spinner in place of its results.
|
||||
*/
|
||||
private fun getPendingSectionRows(section: ContactSearchConfiguration.Section): List<ContactSearchData> {
|
||||
val loading = ContactSearchData.SectionLoading(section.sectionKey)
|
||||
|
||||
return if (section.includeHeader) {
|
||||
listOf(ContactSearchData.Header(section.sectionKey, section.headerAction), loading)
|
||||
} else {
|
||||
listOf(loading)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isPossiblyPhoneNumber(query: String?): Boolean {
|
||||
if (query == null) {
|
||||
return false
|
||||
@@ -276,19 +300,23 @@ class ContactSearchPagedDataSource(
|
||||
return CursorSearchIterator(contactSearchPagedDataSourceRepository.getStories(query))
|
||||
}
|
||||
|
||||
private fun getGroupsWithMembersIterator(query: String?): ContactSearchIterator<Cursor> {
|
||||
private fun getGroupsWithMembersIterator(query: String?): ContactSearchIterator<GroupWithMembersRecord> {
|
||||
sectionResults.groupsWithMembers?.let { return ListSearchIterator(it) }
|
||||
|
||||
return if (query.isNullOrEmpty()) {
|
||||
CursorSearchIterator(null)
|
||||
ListSearchIterator(emptyList())
|
||||
} else {
|
||||
CursorSearchIterator(contactSearchPagedDataSourceRepository.getGroupsWithMembers(query))
|
||||
ListSearchIterator(contactSearchPagedDataSourceRepository.getGroupsWithMembers(query))
|
||||
}
|
||||
}
|
||||
|
||||
private fun getContactsWithoutThreadsIterator(query: String?): ContactSearchIterator<Cursor> {
|
||||
private fun getContactsWithoutThreadsIterator(query: String?): ContactSearchIterator<RecipientId> {
|
||||
sectionResults.contactsWithoutThreads?.let { return ListSearchIterator(it) }
|
||||
|
||||
return if (query.isNullOrEmpty()) {
|
||||
CursorSearchIterator(null)
|
||||
ListSearchIterator(emptyList())
|
||||
} else {
|
||||
CursorSearchIterator(contactSearchPagedDataSourceRepository.getContactsWithoutThreads(query))
|
||||
ListSearchIterator(contactSearchPagedDataSourceRepository.getContactsWithoutThreads(query))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,10 +376,7 @@ class ContactSearchPagedDataSource(
|
||||
section = section,
|
||||
startIndex = startIndex,
|
||||
endIndex = endIndex,
|
||||
recordMapper = { cursor ->
|
||||
val record = GroupTable.Reader(cursor).getCurrent()
|
||||
ContactSearchData.GroupWithMembers(query!!, record!!, cursor.requireLong(GroupTable.THREAD_DATE))
|
||||
}
|
||||
recordMapper = { ContactSearchData.GroupWithMembers(query ?: "", it.groupRecord, it.threadDate) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -395,7 +420,7 @@ class ContactSearchPagedDataSource(
|
||||
startIndex = startIndex,
|
||||
endIndex = endIndex,
|
||||
recordMapper = {
|
||||
ContactSearchData.KnownRecipient(section.sectionKey, contactSearchPagedDataSourceRepository.getRecipientFromRecipientCursor(it))
|
||||
ContactSearchData.KnownRecipient(section.sectionKey, contactSearchPagedDataSourceRepository.getRecipient(it))
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -485,6 +510,8 @@ class ContactSearchPagedDataSource(
|
||||
}
|
||||
|
||||
private fun getMessageData(query: String?): ContactSearchIterator<MessageResult> {
|
||||
sectionResults.messages?.let { return ListSearchIterator(it) }
|
||||
|
||||
check(searchRepository != null)
|
||||
|
||||
if (searchCache.messageSearchResult == null && query != null) {
|
||||
@@ -514,6 +541,8 @@ class ContactSearchPagedDataSource(
|
||||
}
|
||||
|
||||
private fun getThreadData(query: String?, unreadOnly: Boolean): ContactSearchIterator<ThreadWithRecipient> {
|
||||
sectionResults.threads?.let { return ListSearchIterator(it) }
|
||||
|
||||
check(searchRepository != null)
|
||||
if (searchCache.threadSearchResult == null && query != null) {
|
||||
searchCache = searchCache.copy(threadSearchResult = searchRepository.queryThreadsSync(query, unreadOnly))
|
||||
|
||||
+26
-6
@@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.signal.core.util.CursorUtil
|
||||
import org.signal.core.util.LRUCache
|
||||
import org.signal.core.util.requireLong
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.contacts.ContactRepository
|
||||
import org.thoughtcrime.securesms.contacts.paged.collections.ContactSearchIterator
|
||||
@@ -34,6 +35,11 @@ open class ContactSearchPagedDataSourceRepository(
|
||||
selfTitle: String = context.getString(R.string.note_to_self)
|
||||
) {
|
||||
|
||||
companion object {
|
||||
/** Ceiling on the sections that are read into memory in full, rather than a page at a time. */
|
||||
private const val MAX_PREFETCHED_ROWS = 500
|
||||
}
|
||||
|
||||
private val contactRepository = ContactRepository(selfTitle)
|
||||
private val context = context.applicationContext
|
||||
private val groupRecordCache = LRUCache<GroupId, GroupRecord?>(100)
|
||||
@@ -82,12 +88,26 @@ open class ContactSearchPagedDataSourceRepository(
|
||||
return SignalDatabase.distributionLists.getAllListsForContactSelectionUiCursor(query, myStoryContainsQuery(query ?: ""))
|
||||
}
|
||||
|
||||
open fun getGroupsWithMembers(query: String): Cursor {
|
||||
return SignalDatabase.groups.queryGroupsByMemberName(query)
|
||||
open fun getGroupsWithMembers(query: String): List<GroupWithMembersRecord> {
|
||||
val cursor = SignalDatabase.groups.queryGroupsByMemberName(query, MAX_PREFETCHED_ROWS)
|
||||
|
||||
return GroupTable.Reader(cursor).use { reader ->
|
||||
generateSequence { reader.getNext() }
|
||||
.map { GroupWithMembersRecord(it, cursor.requireLong(GroupTable.THREAD_DATE)) }
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
|
||||
open fun getContactsWithoutThreads(query: String): Cursor {
|
||||
return SignalDatabase.recipients.getAllContactsWithoutThreads(query)
|
||||
/**
|
||||
* Ids rather than [Recipient]s, since resolving a recipient costs a query per row and only the
|
||||
* rows that scroll into view need one.
|
||||
*/
|
||||
open fun getContactsWithoutThreads(query: String): List<RecipientId> {
|
||||
return SignalDatabase.recipients.getAllContactsWithoutThreads(query, MAX_PREFETCHED_ROWS).use { cursor ->
|
||||
generateSequence { if (cursor.moveToNext()) cursor else null }
|
||||
.map { RecipientId.from(it.requireLong(RecipientTable.ID)) }
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
|
||||
open fun getRecipientFromDistributionListCursor(cursor: Cursor): Recipient {
|
||||
@@ -106,8 +126,8 @@ open class ContactSearchPagedDataSourceRepository(
|
||||
return Recipient.resolved(RecipientId.from(CursorUtil.requireLong(cursor, ContactRepository.ID_COLUMN)))
|
||||
}
|
||||
|
||||
open fun getRecipientFromRecipientCursor(cursor: Cursor): Recipient {
|
||||
return Recipient.resolved(RecipientId.from(CursorUtil.requireLong(cursor, RecipientTable.ID)))
|
||||
open fun getRecipient(recipientId: RecipientId): Recipient {
|
||||
return Recipient.resolved(recipientId)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.contacts.paged
|
||||
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration.SectionKey
|
||||
import org.thoughtcrime.securesms.database.model.ThreadWithRecipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.search.MessageResult
|
||||
|
||||
/**
|
||||
* The results of the search sections that [ContactSearchViewModel] queries in parallel, folded in
|
||||
* as each one lands so that finished sections can render while the rest are still running.
|
||||
*
|
||||
* A section listed in [pending] has not finished yet, and renders as a loading placeholder rather
|
||||
* than as an empty section.
|
||||
*/
|
||||
data class ContactSearchSectionResults(
|
||||
val pending: Set<SectionKey> = emptySet(),
|
||||
val threads: List<ThreadWithRecipient>? = null,
|
||||
val messages: List<MessageResult>? = null,
|
||||
val groupsWithMembers: List<GroupWithMembersRecord>? = null,
|
||||
val contactsWithoutThreads: List<RecipientId>? = null
|
||||
) {
|
||||
fun withSection(result: ContactSearchSectionResult): ContactSearchSectionResults {
|
||||
val remaining = pending - result.sectionKey
|
||||
|
||||
return when (result) {
|
||||
is ContactSearchSectionResult.Chats -> copy(pending = remaining, threads = result.threads)
|
||||
is ContactSearchSectionResult.Messages -> copy(pending = remaining, messages = result.messages)
|
||||
is ContactSearchSectionResult.GroupsWithMembers -> copy(pending = remaining, groupsWithMembers = result.groups)
|
||||
is ContactSearchSectionResult.ContactsWithoutThreads -> copy(pending = remaining, contactsWithoutThreads = result.recipientIds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The result of querying a single section. Rows are held in whatever form is cheapest to produce in
|
||||
* bulk -- ids where resolving the full model costs a query per row -- and are turned into
|
||||
* [ContactSearchData] lazily, as they scroll into view.
|
||||
*/
|
||||
sealed interface ContactSearchSectionResult {
|
||||
val sectionKey: SectionKey
|
||||
|
||||
data class Chats(val threads: List<ThreadWithRecipient>) : ContactSearchSectionResult {
|
||||
override val sectionKey: SectionKey = SectionKey.CHATS
|
||||
}
|
||||
|
||||
data class Messages(val messages: List<MessageResult>) : ContactSearchSectionResult {
|
||||
override val sectionKey: SectionKey = SectionKey.MESSAGES
|
||||
}
|
||||
|
||||
data class GroupsWithMembers(val groups: List<GroupWithMembersRecord>) : ContactSearchSectionResult {
|
||||
override val sectionKey: SectionKey = SectionKey.GROUPS_WITH_MEMBERS
|
||||
}
|
||||
|
||||
data class ContactsWithoutThreads(val recipientIds: List<RecipientId>) : ContactSearchSectionResult {
|
||||
override val sectionKey: SectionKey = SectionKey.CONTACTS_WITHOUT_THREADS
|
||||
}
|
||||
}
|
||||
+99
-12
@@ -1,5 +1,6 @@
|
||||
package org.thoughtcrime.securesms.contacts.paged
|
||||
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.AbstractSavedStateViewModelFactory
|
||||
import androidx.lifecycle.Lifecycle
|
||||
@@ -14,11 +15,14 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.drop
|
||||
@@ -97,7 +101,11 @@ class ContactSearchViewModel(
|
||||
val isDisplayingContextMenu: StateFlow<Boolean> = internalDisplayingContextMenu
|
||||
val scrollRequests: SharedFlow<ScrollRequest> = internalScrollRequests
|
||||
|
||||
/** True while a [setConfiguration] call is fetching results off the main thread. Suitable for driving a loading indicator. */
|
||||
/**
|
||||
* True while a [setConfiguration] call has yet to publish anything for the current configuration. Suitable for driving a
|
||||
* whole-list loading indicator. Goes false as soon as the first results are published, even if slower sections are still
|
||||
* running -- those render their own placeholder.
|
||||
*/
|
||||
val searchInProgress: StateFlow<Boolean> = internalSearchInProgress
|
||||
|
||||
val query: StateFlow<String?> = rawQuery
|
||||
@@ -154,25 +162,104 @@ class ContactSearchViewModel(
|
||||
internalScrollRequests.tryEmit(ScrollRequest(position))
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the results for [contactSearchConfiguration] and publishes them to [pagedData].
|
||||
*
|
||||
* Each query-driven section runs its own database query, and how long those take varies wildly --
|
||||
* a message search can take an order of magnitude longer than a contact search. So rather than
|
||||
* hold every section back until the slowest one finishes, they are queried concurrently and
|
||||
* published as they land. A section that hasn't reported yet renders as a loading placeholder, so
|
||||
* an outstanding section can't be mistaken for an empty one.
|
||||
*
|
||||
* Nothing is published until the topmost section finishes, even if a lower one is ready sooner.
|
||||
* Publishing earlier would put a placeholder above the results and then shove them down when it
|
||||
* resolves, which on a device where the whole search takes a few milliseconds reads as a flicker.
|
||||
* Until then [searchInProgress] covers the gap, and sections that finish together are published
|
||||
* together for the same reason.
|
||||
*/
|
||||
suspend fun setConfiguration(contactSearchConfiguration: ContactSearchConfiguration) {
|
||||
internalSearchInProgress.value = true
|
||||
try {
|
||||
val (pagedDataSource, size) = withContext(Dispatchers.Default) {
|
||||
val source = ContactSearchPagedDataSource(
|
||||
contactSearchConfiguration,
|
||||
arbitraryRepository = arbitraryRepository,
|
||||
searchRepository = searchRepository,
|
||||
contactSearchPagedDataSourceRepository = contactSearchPagedDataSourceRepository
|
||||
)
|
||||
source to source.size()
|
||||
val query = contactSearchConfiguration.query?.takeIf { it.isNotEmpty() }
|
||||
val parallelSections = if (query == null) emptyList() else contactSearchConfiguration.sections.filter { it.isQueriedInParallel() }
|
||||
|
||||
if (query == null || parallelSections.isEmpty()) {
|
||||
publish(contactSearchConfiguration, ContactSearchSectionResults())
|
||||
return
|
||||
}
|
||||
|
||||
coroutineScope {
|
||||
val completedSections = Channel<ContactSearchSectionResult>(capacity = parallelSections.size)
|
||||
|
||||
parallelSections.forEach { section ->
|
||||
launch(Dispatchers.Default) {
|
||||
completedSections.send(querySection(section, query, contactSearchConfiguration.searchFilter))
|
||||
}
|
||||
}
|
||||
|
||||
val topSection = parallelSections.first().sectionKey
|
||||
var results = ContactSearchSectionResults(pending = parallelSections.mapTo(mutableSetOf()) { it.sectionKey })
|
||||
|
||||
while (results.pending.isNotEmpty()) {
|
||||
results = results.withSection(completedSections.receive())
|
||||
|
||||
generateSequence { completedSections.tryReceive().getOrNull() }
|
||||
.forEach { results = results.withSection(it) }
|
||||
|
||||
if (topSection !in results.pending) {
|
||||
publish(contactSearchConfiguration, results)
|
||||
internalSearchInProgress.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
internalTotalCount.value = size
|
||||
pagedData.value = PagedData.createForStateFlow(pagedDataSource, pagingConfig, data.value)
|
||||
} finally {
|
||||
internalSearchInProgress.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [querySection] can produce this section's rows up front. The rest are left to
|
||||
* [ContactSearchPagedDataSource], which reads them a page at a time off of a cursor -- either
|
||||
* because they're unbounded, or because they aren't query-driven and so aren't what a search is
|
||||
* waiting on.
|
||||
*/
|
||||
private fun ContactSearchConfiguration.Section.isQueriedInParallel(): Boolean {
|
||||
return when (this) {
|
||||
is ContactSearchConfiguration.Section.Chats,
|
||||
is ContactSearchConfiguration.Section.Messages,
|
||||
is ContactSearchConfiguration.Section.GroupsWithMembers,
|
||||
is ContactSearchConfiguration.Section.ContactsWithoutThreads -> true
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun querySection(section: ContactSearchConfiguration.Section, query: String, searchFilter: SearchFilter): ContactSearchSectionResult {
|
||||
return when (section) {
|
||||
is ContactSearchConfiguration.Section.Chats -> ContactSearchSectionResult.Chats(searchRepository.queryThreadsSync(query, section.isUnreadOnly).results)
|
||||
is ContactSearchConfiguration.Section.Messages -> ContactSearchSectionResult.Messages(searchRepository.queryMessagesSync(query, searchFilter).results)
|
||||
is ContactSearchConfiguration.Section.GroupsWithMembers -> ContactSearchSectionResult.GroupsWithMembers(contactSearchPagedDataSourceRepository.getGroupsWithMembers(query))
|
||||
is ContactSearchConfiguration.Section.ContactsWithoutThreads -> ContactSearchSectionResult.ContactsWithoutThreads(contactSearchPagedDataSourceRepository.getContactsWithoutThreads(query))
|
||||
else -> error("Section ${section.sectionKey} is not queried in parallel.")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun publish(contactSearchConfiguration: ContactSearchConfiguration, sectionResults: ContactSearchSectionResults) {
|
||||
val (pagedDataSource, size) = withContext(Dispatchers.Default) {
|
||||
val source = ContactSearchPagedDataSource(
|
||||
contactSearchConfiguration,
|
||||
arbitraryRepository = arbitraryRepository,
|
||||
searchRepository = searchRepository,
|
||||
contactSearchPagedDataSourceRepository = contactSearchPagedDataSourceRepository,
|
||||
sectionResults = sectionResults
|
||||
)
|
||||
source to source.size()
|
||||
}
|
||||
internalTotalCount.value = size
|
||||
pagedData.value = PagedData.createForStateFlow(pagedDataSource, pagingConfig, data.value)
|
||||
}
|
||||
|
||||
fun setQuery(query: String?) {
|
||||
rawQuery.value = query
|
||||
}
|
||||
@@ -322,7 +409,7 @@ fun ContactSearchViewModel.bindAdapterToLifecycle(
|
||||
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
launch { mappingModels.collect { adapter.submitList(it) } }
|
||||
launch { controller.collect { it?.let { c -> adapter.setPagingController(c) } } }
|
||||
launch { configurationState.collect { setConfiguration(mapStateToConfiguration(it)) } }
|
||||
launch { configurationState.collectLatest { setConfiguration(mapStateToConfiguration(it)) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.contacts.paged
|
||||
|
||||
import org.thoughtcrime.securesms.database.model.GroupRecord
|
||||
|
||||
/**
|
||||
* A group whose members matched a search query, paired with the date of its thread.
|
||||
*/
|
||||
data class GroupWithMembersRecord(
|
||||
val groupRecord: GroupRecord,
|
||||
val threadDate: Long
|
||||
)
|
||||
@@ -351,22 +351,23 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
|
||||
return noMetadata && noMembers
|
||||
}
|
||||
|
||||
fun queryGroupsByMemberName(inputQuery: String): Cursor {
|
||||
fun queryGroupsByMemberName(inputQuery: String, limit: Int): Cursor {
|
||||
val subquery = recipients.getAllContactsSubquery(inputQuery, RecipientTable.IncludeSelfMode.IncludeWithoutRemap)
|
||||
val statement = """
|
||||
SELECT
|
||||
DISTINCT $TABLE_NAME.*,
|
||||
SELECT
|
||||
DISTINCT $TABLE_NAME.*,
|
||||
GROUP_CONCAT(${MembershipTable.TABLE_NAME}.${MembershipTable.RECIPIENT_ID}) as $MEMBER_GROUP_CONCAT,
|
||||
${ThreadTable.TABLE_NAME}.${ThreadTable.DATE} as $THREAD_DATE
|
||||
FROM $TABLE_NAME
|
||||
FROM $TABLE_NAME
|
||||
INNER JOIN ${MembershipTable.TABLE_NAME} ON ${MembershipTable.TABLE_NAME}.${MembershipTable.GROUP_ID} = $TABLE_NAME.$GROUP_ID
|
||||
INNER JOIN ${ThreadTable.TABLE_NAME} ON ${ThreadTable.TABLE_NAME}.${ThreadTable.RECIPIENT_ID} = $TABLE_NAME.$RECIPIENT_ID
|
||||
WHERE $TABLE_NAME.$IS_MEMBER = 1 AND $TABLE_NAME.$TERMINATED_BY = 0 AND ${MembershipTable.TABLE_NAME}.${MembershipTable.RECIPIENT_ID} IN (${subquery.where})
|
||||
GROUP BY ${MembershipTable.TABLE_NAME}.${MembershipTable.GROUP_ID}
|
||||
ORDER BY $TITLE COLLATE NOCASE ASC
|
||||
LIMIT ?
|
||||
"""
|
||||
|
||||
return databaseHelper.signalReadableDatabase.query(statement, subquery.whereArgs)
|
||||
return databaseHelper.signalReadableDatabase.query(statement, subquery.whereArgs + limit.toString())
|
||||
}
|
||||
|
||||
fun queryGroupsByTitle(inputQuery: String, includeInactive: Boolean, excludeV1: Boolean, excludeMms: Boolean): Reader {
|
||||
|
||||
@@ -3787,7 +3787,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
|
||||
/**
|
||||
* Queries all contacts without an active thread.
|
||||
*/
|
||||
fun getAllContactsWithoutThreads(inputQuery: String): Cursor {
|
||||
fun getAllContactsWithoutThreads(inputQuery: String, limit: Int): Cursor {
|
||||
val query = SqlUtil.buildCaseInsensitiveGlobPattern(inputQuery)
|
||||
|
||||
//language=sql
|
||||
@@ -3795,14 +3795,15 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
|
||||
SELECT ${searchProjection(IncludeSelfMode.Exclude).joinToString(", ")} FROM $TABLE_NAME
|
||||
WHERE $BLOCKED = ? AND $HIDDEN = ? AND $REGISTERED != ? AND NOT EXISTS (SELECT 1 FROM ${ThreadTable.TABLE_NAME} WHERE ${ThreadTable.TABLE_NAME}.${ThreadTable.ACTIVE} = 1 AND ${ThreadTable.TABLE_NAME}.${ThreadTable.RECIPIENT_ID} = $TABLE_NAME.$ID LIMIT 1)
|
||||
AND (
|
||||
$SORT_NAME GLOB ? OR
|
||||
$USERNAME GLOB ? OR
|
||||
${ContactSearchSelection.E164_SEARCH} OR
|
||||
$SORT_NAME GLOB ? OR
|
||||
$USERNAME GLOB ? OR
|
||||
${ContactSearchSelection.E164_SEARCH} OR
|
||||
$EMAIL GLOB ?
|
||||
)
|
||||
LIMIT ?
|
||||
"""
|
||||
|
||||
return readableDatabase.query(subquery, SqlUtil.buildArgs(0, 0, RegisteredState.NOT_REGISTERED.id, query, query, query, query))
|
||||
return readableDatabase.query(subquery, SqlUtil.buildArgs(0, 0, RegisteredState.NOT_REGISTERED.id, query, query, query, query, limit))
|
||||
}
|
||||
|
||||
@JvmOverloads
|
||||
|
||||
Reference in New Issue
Block a user