mirror of
https://github.com/oxen-io/session-android.git
synced 2024-11-23 18:15:22 +00:00
Update empty search
This commit is contained in:
parent
afd240dcce
commit
1f1c51669c
@ -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
|
||||
@ -280,6 +283,30 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
|
||||
// Get group results and display them
|
||||
launch {
|
||||
globalSearchViewModel.result.collect { result ->
|
||||
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) }
|
||||
@ -304,19 +331,15 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
|
||||
.associateWith { mmsSmsDatabase.getUnreadCount(it) }
|
||||
|
||||
val messageResults: MutableList<GlobalSearchAdapter.Model> = result.messages
|
||||
.map { messageResult ->
|
||||
GlobalSearchAdapter.Model.Message(
|
||||
messageResult,
|
||||
unreadThreadMap[messageResult.threadId] ?: 0
|
||||
)
|
||||
}.toMutableList()
|
||||
.map { GlobalSearchAdapter.Model.Message(it, unreadThreadMap[it.threadId] ?: 0) }
|
||||
.toMutableList()
|
||||
|
||||
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<NameIdContact> {
|
||||
override fun compareTo(other: NameIdContact): Int = comparator.compare(this, other)
|
||||
companion object {
|
||||
val comparator = compareBy<NameIdContact>({ it.name }, { it.id })
|
||||
}
|
||||
}
|
||||
|
@ -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<Model> = 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()
|
||||
}
|
||||
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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<Contact>,
|
||||
val threads: List<GroupRecord>,
|
||||
val messages: List<MessageResult>
|
||||
val contacts: List<Contact> = emptyList(),
|
||||
val threads: List<GroupRecord> = emptyList(),
|
||||
val messages: List<MessageResult> = 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()
|
||||
}
|
||||
|
@ -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)
|
||||
_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)
|
||||
|
||||
if (query.trim().isEmpty()) {
|
||||
GlobalSearchResult(query.toString(), searchRepository.queryContacts("05").first.toList())
|
||||
} else {
|
||||
val settableFuture = SettableFuture<SearchResult>()
|
||||
searchRepository.query(query.toString(), settableFuture::set)
|
||||
try {
|
||||
// search repository doesn't play nicely with suspend functions (yet)
|
||||
settableFuture.get(10_000, TimeUnit.MILLISECONDS)
|
||||
settableFuture.get(10_000, TimeUnit.MILLISECONDS).toGlobalSearchResult()
|
||||
} catch (e: Exception) {
|
||||
SearchResult.EMPTY
|
||||
GlobalSearchResult(query.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
.onEach { result ->
|
||||
}.onEach { result ->
|
||||
// update the latest _result value
|
||||
_result.value = GlobalSearchResult.from(result)
|
||||
}
|
||||
.launchIn(executor)
|
||||
_result.value = result
|
||||
}.launchIn(executor)
|
||||
}
|
||||
}
|
@ -72,10 +72,6 @@ public class SearchRepository {
|
||||
public void query(@NonNull String query, @NonNull Callback<SearchResult> 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<CursorList<Contact>, List<String>> queryContacts(String query) {
|
||||
public Pair<CursorList<Contact>, List<String>> queryContacts(String query) {
|
||||
Cursor contacts = contactDatabase.queryContactsByName(query);
|
||||
List<Address> contactList = new ArrayList<>();
|
||||
List<String> contactStrings = new ArrayList<>();
|
||||
|
@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ripple
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:color="?android:colorControlHighlight">
|
||||
|
||||
<item>
|
||||
<color android:color="?colorPrimary" />
|
||||
</item>
|
||||
</ripple>
|
@ -2,7 +2,7 @@
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:background="@drawable/conversation_view_background"
|
||||
android:background="@drawable/conversation_view_search__background"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
|
15
app/src/main/res/layout/view_global_search_subheader.xml
Normal file
15
app/src/main/res/layout/view_global_search_subheader.xml
Normal file
@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?colorPrimary"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<TextView
|
||||
tools:text="@string/global_search_messages"
|
||||
android:textSize="14sp"
|
||||
android:paddingVertical="@dimen/medium_spacing"
|
||||
android:paddingHorizontal="@dimen/very_large_spacing"
|
||||
android:id="@+id/search_header"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_width="match_parent"/>
|
||||
</LinearLayout>
|
@ -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) {
|
||||
fun displayName(context: ContactContext): String? = nickname ?: 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)})"
|
||||
ContactContext.OPEN_GROUP -> name?.let { "$it (${sessionID.take(4)}...${sessionID.takeLast(4)})" }
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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
|
||||
}
|
||||
|
@ -383,3 +383,9 @@ fun <T, K: Any, V: Any> Iterable<T>.associateByNotNull(
|
||||
this[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <T, K> Iterable<T>.groupByNotNull(keySelector: (T) -> K?): Map<K, List<T>> {
|
||||
val map = mutableMapOf<K, MutableList<T>>()
|
||||
forEach { e -> keySelector(e)?.let { k -> map.getOrPut(k) { mutableListOf() }.add(e) } }
|
||||
return map
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user