diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt index 8443181c5e..d59f72e14f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -59,6 +59,7 @@ import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.snode.SnodeAPI @@ -66,6 +67,8 @@ import org.session.libsession.utilities.Address import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.ProfilePictureModifiedEvent import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.associateByNotNull +import org.session.libsession.utilities.groupByNotNull import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.ThreadUtils @@ -274,49 +277,69 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), // monitor the global search VM query launch { binding.globalSearchInputLayout.query - .onEach(globalSearchViewModel::postQuery) - .collect() + .onEach(globalSearchViewModel::postQuery) + .collect() } // Get group results and display them launch { globalSearchViewModel.result.collect { result -> - val currentUserPublicKey = publicKey - val contactAndGroupList = result.contacts.map { GlobalSearchAdapter.Model.Contact(it) } + + if (result.query.isEmpty()) { + val hasNames = result.contacts.filter { it.nickname != null || it.name != null } + .groupByNotNull { (it.nickname?.firstOrNull() ?: it.name?.firstOrNull())?.uppercase() } + .toSortedMap(compareBy { it }) + .flatMap { (key, contacts) -> listOf(GlobalSearchAdapter.Model.SubHeader(key)) + contacts.map(GlobalSearchAdapter.Model::Contact) } + + val noNames = result.contacts.filter { it.nickname == null && it.name == null } + .sortedBy { it.sessionID } + .map { GlobalSearchAdapter.Model.Contact(it) } + .takeIf { it.isNotEmpty() } + ?.let { + buildList { + add(GlobalSearchAdapter.Model.Header("Unknown")) + addAll(it) + } + } ?: emptyList() + + buildList { + add(GlobalSearchAdapter.Model.Header("Contacts")) + add(GlobalSearchAdapter.Model.SavedMessages(publicKey)) + addAll(hasNames) + addAll(noNames) + } + } else { + val currentUserPublicKey = publicKey + val contactAndGroupList = result.contacts.map { GlobalSearchAdapter.Model.Contact(it) } + result.threads.map { GlobalSearchAdapter.Model.GroupConversation(it) } - val contactResults = contactAndGroupList.toMutableList() + val contactResults = contactAndGroupList.toMutableList() - if (contactResults.isEmpty()) { - contactResults.add(GlobalSearchAdapter.Model.SavedMessages(currentUserPublicKey)) - } + if (contactResults.isEmpty()) { + contactResults.add(GlobalSearchAdapter.Model.SavedMessages(currentUserPublicKey)) + } - val userIndex = contactResults.indexOfFirst { it is GlobalSearchAdapter.Model.Contact && it.contact.sessionID == currentUserPublicKey } - if (userIndex >= 0) { - contactResults[userIndex] = GlobalSearchAdapter.Model.SavedMessages(currentUserPublicKey) - } + val userIndex = contactResults.indexOfFirst { it is GlobalSearchAdapter.Model.Contact && it.contact.sessionID == currentUserPublicKey } + if (userIndex >= 0) { + contactResults[userIndex] = GlobalSearchAdapter.Model.SavedMessages(currentUserPublicKey) + } - if (contactResults.isNotEmpty()) { - contactResults.add(0, GlobalSearchAdapter.Model.Header(R.string.global_search_contacts_groups)) - } + if (contactResults.isNotEmpty()) { + contactResults.add(0, GlobalSearchAdapter.Model.Header(R.string.global_search_contacts_groups)) + } - val unreadThreadMap = result.messages - .map { it.threadId }.toSet() - .associateWith { mmsSmsDatabase.getUnreadCount(it) } + val unreadThreadMap = result.messages + .map { it.threadId }.toSet() + .associateWith { mmsSmsDatabase.getUnreadCount(it) } - val messageResults: MutableList = result.messages - .map { messageResult -> - GlobalSearchAdapter.Model.Message( - messageResult, - unreadThreadMap[messageResult.threadId] ?: 0 - ) - }.toMutableList() + val messageResults: MutableList = result.messages + .map { GlobalSearchAdapter.Model.Message(it, unreadThreadMap[it.threadId] ?: 0) } + .toMutableList() - if (messageResults.isNotEmpty()) { - messageResults.add(0, GlobalSearchAdapter.Model.Header(R.string.global_search_messages)) - } + if (messageResults.isNotEmpty()) { + messageResults.add(0, GlobalSearchAdapter.Model.Header(R.string.global_search_messages)) + } - val newData = contactResults + messageResults - globalSearchAdapter.setNewData(result.query, newData) + contactResults + messageResults + }.let { globalSearchAdapter.setNewData(result.query, it) } } } } @@ -586,7 +609,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() } else if (thread.recipient.isCommunityRecipient) { - val threadId = threadDb.getThreadIdIfExistsFor(thread.recipient) ?: return@onCopyConversationId Unit + val threadId = threadDb.getThreadIdIfExistsFor(thread.recipient) val openGroup = DatabaseComponent.get(this@HomeActivity).lokiThreadDatabase().getOpenGroupChat(threadId) ?: return@onCopyConversationId Unit val clip = ClipData.newPlainText("Community URL", openGroup.joinURL) @@ -785,3 +808,10 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), // endregion } + +data class NameIdContact(val name: String?, val id: String, val contact: Contact): Comparable { + override fun compareTo(other: NameIdContact): Int = comparator.compare(this, other) + companion object { + val comparator = compareBy({ it.name }, { it.id }) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt index 7cf953be24..4bcf00b72d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt @@ -9,10 +9,11 @@ import androidx.recyclerview.widget.RecyclerView import network.loki.messenger.R import network.loki.messenger.databinding.ViewGlobalSearchHeaderBinding import network.loki.messenger.databinding.ViewGlobalSearchResultBinding +import network.loki.messenger.databinding.ViewGlobalSearchSubheaderBinding import org.session.libsession.utilities.GroupRecord import org.session.libsession.utilities.recipients.Recipient -import org.thoughtcrime.securesms.mms.GlideApp import org.thoughtcrime.securesms.search.model.MessageResult +import org.thoughtcrime.securesms.ui.GetString import java.security.InvalidParameterException import org.session.libsession.messaging.contacts.Contact as ContactModel @@ -20,7 +21,8 @@ class GlobalSearchAdapter (private val modelCallback: (Model)->Unit): RecyclerVi companion object { const val HEADER_VIEW_TYPE = 0 - const val CONTENT_VIEW_TYPE = 1 + const val SUB_HEADER_VIEW_TYPE = 1 + const val CONTENT_VIEW_TYPE = 2 } private var data: List = listOf() @@ -34,21 +36,26 @@ class GlobalSearchAdapter (private val modelCallback: (Model)->Unit): RecyclerVi } override fun getItemViewType(position: Int): Int = - if (data[position] is Model.Header) HEADER_VIEW_TYPE else CONTENT_VIEW_TYPE + when(data[position]) { + is Model.Header -> HEADER_VIEW_TYPE + is Model.SubHeader -> SUB_HEADER_VIEW_TYPE + else -> CONTENT_VIEW_TYPE + } override fun getItemCount(): Int = data.size override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder = - if (viewType == HEADER_VIEW_TYPE) { - HeaderView( - LayoutInflater.from(parent.context) - .inflate(R.layout.view_global_search_header, parent, false) + when (viewType) { + HEADER_VIEW_TYPE -> HeaderView( + LayoutInflater.from(parent.context).inflate(R.layout.view_global_search_header, parent, false) + ) + SUB_HEADER_VIEW_TYPE -> SubHeaderView( + LayoutInflater.from(parent.context).inflate(R.layout.view_global_search_subheader, parent, false) + ) + else -> ContentView( + LayoutInflater.from(parent.context).inflate(R.layout.view_global_search_result, parent, false), + modelCallback ) - } else { - ContentView( - LayoutInflater.from(parent.context) - .inflate(R.layout.view_global_search_result, parent, false) - , modelCallback) } override fun onBindViewHolder( @@ -61,10 +68,10 @@ class GlobalSearchAdapter (private val modelCallback: (Model)->Unit): RecyclerVi holder.bindPayload(newUpdateQuery, data[position]) return } - if (holder is HeaderView) { - holder.bind(data[position] as Model.Header) - } else if (holder is ContentView) { - holder.bind(query.orEmpty(), data[position]) + when (holder) { + is HeaderView -> holder.bind(data[position] as Model.Header) + is SubHeaderView -> holder.bind(data[position] as Model.SubHeader) + is ContentView -> holder.bind(query.orEmpty(), data[position]) } } @@ -77,7 +84,16 @@ class GlobalSearchAdapter (private val modelCallback: (Model)->Unit): RecyclerVi val binding = ViewGlobalSearchHeaderBinding.bind(view) fun bind(header: Model.Header) { - binding.searchHeader.setText(header.title) + binding.searchHeader.setText(header.title.string(binding.root.context)) + } + } + + class SubHeaderView(view: View) : RecyclerView.ViewHolder(view) { + + val binding = ViewGlobalSearchSubheaderBinding.bind(view) + + fun bind(header: Model.SubHeader) { + binding.searchHeader.text = header.title.string(binding.root.context) } } @@ -102,7 +118,7 @@ class GlobalSearchAdapter (private val modelCallback: (Model)->Unit): RecyclerVi is Model.Contact -> bindModel(query, model) is Model.Message -> bindModel(query, model) is Model.SavedMessages -> bindModel(model) - is Model.Header -> throw InvalidParameterException("Can't display Model.Header as ContentView") + else -> throw InvalidParameterException("Can't display as ContentView") } binding.root.setOnClickListener { modelCallback(model) } } @@ -116,11 +132,17 @@ class GlobalSearchAdapter (private val modelCallback: (Model)->Unit): RecyclerVi ) sealed class Model { - data class Header(@StringRes val title: Int) : Model() + data class Header(val title: GetString): Model() { + constructor(@StringRes title: Int): this(GetString(title)) + constructor(title: String): this(GetString(title)) + } + data class SubHeader(val title: GetString): Model() { + constructor(@StringRes title: Int): this(GetString(title)) + constructor(title: String): this(GetString(title)) + } data class SavedMessages(val currentUserPublicKey: String): Model() - data class Contact(val contact: ContactModel) : Model() - data class GroupConversation(val groupRecord: GroupRecord) : Model() - data class Message(val messageResult: MessageResult, val unread: Int) : Model() + data class Contact(val contact: ContactModel): Model() + data class GroupConversation(val groupRecord: GroupRecord): Model() + data class Message(val messageResult: MessageResult, val unread: Int): Model() } - -} \ No newline at end of file +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt index 5371bb71c9..d79b6f8887 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt @@ -15,6 +15,7 @@ import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.GroupCon import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Header import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Message import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.SavedMessages +import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.SubHeader import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.SearchUtil import java.util.Locale @@ -78,6 +79,7 @@ fun ContentView.bindQuery(query: String, model: GlobalSearchAdapter.Model) { binding.searchResultSubtitle.text = getHighlight(query, membersString) } is Header, // do nothing for header + is SubHeader, // do nothing for subheader is SavedMessages -> Unit // do nothing for saved messages (displays note to self) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchResult.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchResult.kt index c85ffa8745..29e11067a0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchResult.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchResult.kt @@ -2,33 +2,25 @@ package org.thoughtcrime.securesms.home.search import org.session.libsession.messaging.contacts.Contact import org.session.libsession.utilities.GroupRecord -import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.search.model.MessageResult import org.thoughtcrime.securesms.search.model.SearchResult data class GlobalSearchResult( - val query: String, - val contacts: List, - val threads: List, - val messages: List + val query: String, + val contacts: List = emptyList(), + val threads: List = emptyList(), + val messages: List = emptyList() ) { - val isEmpty: Boolean get() = contacts.isEmpty() && threads.isEmpty() && messages.isEmpty() companion object { - - val EMPTY = GlobalSearchResult("", emptyList(), emptyList(), emptyList()) - const val SEARCH_LIMIT = 5 - - fun from(searchResult: SearchResult): GlobalSearchResult { - val query = searchResult.query - val contactList = searchResult.contacts.toList() - val threads = searchResult.conversations.toList() - val messages = searchResult.messages.toList() - searchResult.close() - return GlobalSearchResult(query, contactList, threads, messages) - } - + val EMPTY = GlobalSearchResult("") } } + +fun SearchResult.toGlobalSearchResult(): GlobalSearchResult = try { + GlobalSearchResult(query, contacts.toList(), conversations.toList(), messages.toList()) +} finally { + close() +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchViewModel.kt index 1ff0a395fe..6e07b1adfe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchViewModel.kt @@ -13,14 +13,18 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.plus +import org.session.libsession.utilities.truncateIdForDisplay import org.session.libsignal.utilities.SettableFuture +import org.thoughtcrime.securesms.database.SessionContactDatabase import org.thoughtcrime.securesms.search.SearchRepository import org.thoughtcrime.securesms.search.model.SearchResult import java.util.concurrent.TimeUnit import javax.inject.Inject @HiltViewModel -class GlobalSearchViewModel @Inject constructor(private val searchRepository: SearchRepository) : ViewModel() { +class GlobalSearchViewModel @Inject constructor( + private val searchRepository: SearchRepository, +) : ViewModel() { private val executor = viewModelScope + SupervisorJob() @@ -36,32 +40,27 @@ class GlobalSearchViewModel @Inject constructor(private val searchRepository: Se } init { - // - _queryText - .buffer(onBufferOverflow = BufferOverflow.DROP_OLDEST) - .mapLatest { query -> - // Early exit on empty search query - if (query.trim().isEmpty()) { - SearchResult.EMPTY - } else { - // User input delay in case we get a new query within a few hundred ms this - // coroutine will be cancelled and the expensive query will not be run. - delay(300) + _queryText.buffer(onBufferOverflow = BufferOverflow.DROP_OLDEST) + .mapLatest { query -> + // User input delay in case we get a new query within a few hundred ms this + // coroutine will be cancelled and the expensive query will not be run. + delay(300) - val settableFuture = SettableFuture() - searchRepository.query(query.toString(), settableFuture::set) - try { - // search repository doesn't play nicely with suspend functions (yet) - settableFuture.get(10_000, TimeUnit.MILLISECONDS) - } catch (e: Exception) { - SearchResult.EMPTY - } + if (query.trim().isEmpty()) { + GlobalSearchResult(query.toString(), searchRepository.queryContacts("05").first.toList()) + } else { + val settableFuture = SettableFuture() + searchRepository.query(query.toString(), settableFuture::set) + try { + // search repository doesn't play nicely with suspend functions (yet) + settableFuture.get(10_000, TimeUnit.MILLISECONDS).toGlobalSearchResult() + } catch (e: Exception) { + GlobalSearchResult(query.toString()) } } - .onEach { result -> - // update the latest _result value - _result.value = GlobalSearchResult.from(result) - } - .launchIn(executor) + }.onEach { result -> + // update the latest _result value + _result.value = result + }.launchIn(executor) } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java b/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java index f2adbf2349..f96bf22fa9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java @@ -72,10 +72,6 @@ public class SearchRepository { public void query(@NonNull String query, @NonNull Callback callback) { // If the sanitized search is empty then abort without search String cleanQuery = sanitizeQuery(query).trim(); - if (cleanQuery.isEmpty()) { - callback.onResult(SearchResult.EMPTY); - return; - } executor.execute(() -> { Stopwatch timer = new Stopwatch("FtsQuery"); @@ -110,7 +106,7 @@ public class SearchRepository { }); } - private Pair, List> queryContacts(String query) { + public Pair, List> queryContacts(String query) { Cursor contacts = contactDatabase.queryContactsByName(query); List
contactList = new ArrayList<>(); List contactStrings = new ArrayList<>(); diff --git a/app/src/main/res/drawable/conversation_view_search__background.xml b/app/src/main/res/drawable/conversation_view_search__background.xml new file mode 100644 index 0000000000..50e38698b4 --- /dev/null +++ b/app/src/main/res/drawable/conversation_view_search__background.xml @@ -0,0 +1,9 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_global_search_result.xml b/app/src/main/res/layout/view_global_search_result.xml index cdd90b8d97..b4d88e1823 100644 --- a/app/src/main/res/layout/view_global_search_result.xml +++ b/app/src/main/res/layout/view_global_search_result.xml @@ -2,7 +2,7 @@ + + + \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/contacts/Contact.kt b/libsession/src/main/java/org/session/libsession/messaging/contacts/Contact.kt index 7161d070aa..763245e496 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/contacts/Contact.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/contacts/Contact.kt @@ -36,21 +36,12 @@ class Contact(val sessionID: String) { /** * The name to display in the UI. For local use only. */ - fun displayName(context: ContactContext): String? { - nickname?.let { return it } - return when (context) { - ContactContext.REGULAR -> name - ContactContext.OPEN_GROUP -> { - // In open groups, where it's more likely that multiple users have the same name, - // we display a bit of the Session ID after a user's display name for added context. - name?.let { - return "$name (${sessionID.take(4)}...${sessionID.takeLast(4)})" - } - return null - } - } + fun displayName(context: ContactContext): String? = nickname ?: when (context) { + ContactContext.REGULAR -> name + // In open groups, where it's more likely that multiple users have the same name, + // we display a bit of the Session ID after a user's display name for added context. + ContactContext.OPEN_GROUP -> name?.let { "$it (${sessionID.take(4)}...${sessionID.takeLast(4)})" } } - // endregion enum class ContactContext { REGULAR, OPEN_GROUP @@ -75,7 +66,6 @@ class Contact(val sessionID: String) { } companion object { - fun contextForRecipient(recipient: Recipient): ContactContext { return if (recipient.isCommunityRecipient) ContactContext.OPEN_GROUP else ContactContext.REGULAR } diff --git a/libsession/src/main/java/org/session/libsession/utilities/Util.kt b/libsession/src/main/java/org/session/libsession/utilities/Util.kt index 17009caa7d..cc13142220 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/Util.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/Util.kt @@ -383,3 +383,9 @@ fun Iterable.associateByNotNull( this[key] = value } } + +inline fun Iterable.groupByNotNull(keySelector: (T) -> K?): Map> { + val map = mutableMapOf>() + forEach { e -> keySelector(e)?.let { k -> map.getOrPut(k) { mutableListOf() }.add(e) } } + return map +}