Update empty search

This commit is contained in:
Andrew 2024-05-08 13:16:46 +09:30
parent afd240dcce
commit 1f1c51669c
11 changed files with 182 additions and 121 deletions

View File

@ -59,6 +59,7 @@ import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode import org.greenrobot.eventbus.ThreadMode
import org.session.libsession.messaging.MessagingModuleConfiguration 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.jobs.JobQueue
import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.sending_receiving.MessageSender
import org.session.libsession.snode.SnodeAPI 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.GroupUtil
import org.session.libsession.utilities.ProfilePictureModifiedEvent import org.session.libsession.utilities.ProfilePictureModifiedEvent
import org.session.libsession.utilities.TextSecurePreferences 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.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.ThreadUtils import org.session.libsignal.utilities.ThreadUtils
@ -274,49 +277,69 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
// monitor the global search VM query // monitor the global search VM query
launch { launch {
binding.globalSearchInputLayout.query binding.globalSearchInputLayout.query
.onEach(globalSearchViewModel::postQuery) .onEach(globalSearchViewModel::postQuery)
.collect() .collect()
} }
// Get group results and display them // Get group results and display them
launch { launch {
globalSearchViewModel.result.collect { result -> globalSearchViewModel.result.collect { result ->
val currentUserPublicKey = publicKey if (result.query.isEmpty()) {
val contactAndGroupList = result.contacts.map { GlobalSearchAdapter.Model.Contact(it) } + 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) } result.threads.map { GlobalSearchAdapter.Model.GroupConversation(it) }
val contactResults = contactAndGroupList.toMutableList() val contactResults = contactAndGroupList.toMutableList()
if (contactResults.isEmpty()) { if (contactResults.isEmpty()) {
contactResults.add(GlobalSearchAdapter.Model.SavedMessages(currentUserPublicKey)) contactResults.add(GlobalSearchAdapter.Model.SavedMessages(currentUserPublicKey))
} }
val userIndex = contactResults.indexOfFirst { it is GlobalSearchAdapter.Model.Contact && it.contact.sessionID == currentUserPublicKey } val userIndex = contactResults.indexOfFirst { it is GlobalSearchAdapter.Model.Contact && it.contact.sessionID == currentUserPublicKey }
if (userIndex >= 0) { if (userIndex >= 0) {
contactResults[userIndex] = GlobalSearchAdapter.Model.SavedMessages(currentUserPublicKey) contactResults[userIndex] = GlobalSearchAdapter.Model.SavedMessages(currentUserPublicKey)
} }
if (contactResults.isNotEmpty()) { if (contactResults.isNotEmpty()) {
contactResults.add(0, GlobalSearchAdapter.Model.Header(R.string.global_search_contacts_groups)) contactResults.add(0, GlobalSearchAdapter.Model.Header(R.string.global_search_contacts_groups))
} }
val unreadThreadMap = result.messages val unreadThreadMap = result.messages
.map { it.threadId }.toSet() .map { it.threadId }.toSet()
.associateWith { mmsSmsDatabase.getUnreadCount(it) } .associateWith { mmsSmsDatabase.getUnreadCount(it) }
val messageResults: MutableList<GlobalSearchAdapter.Model> = result.messages val messageResults: MutableList<GlobalSearchAdapter.Model> = result.messages
.map { messageResult -> .map { GlobalSearchAdapter.Model.Message(it, unreadThreadMap[it.threadId] ?: 0) }
GlobalSearchAdapter.Model.Message( .toMutableList()
messageResult,
unreadThreadMap[messageResult.threadId] ?: 0
)
}.toMutableList()
if (messageResults.isNotEmpty()) { if (messageResults.isNotEmpty()) {
messageResults.add(0, GlobalSearchAdapter.Model.Header(R.string.global_search_messages)) messageResults.add(0, GlobalSearchAdapter.Model.Header(R.string.global_search_messages))
} }
val newData = contactResults + messageResults contactResults + messageResults
globalSearchAdapter.setNewData(result.query, newData) }.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() Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show()
} }
else if (thread.recipient.isCommunityRecipient) { 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 openGroup = DatabaseComponent.get(this@HomeActivity).lokiThreadDatabase().getOpenGroupChat(threadId) ?: return@onCopyConversationId Unit
val clip = ClipData.newPlainText("Community URL", openGroup.joinURL) val clip = ClipData.newPlainText("Community URL", openGroup.joinURL)
@ -785,3 +808,10 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
// endregion // 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 })
}
}

View File

@ -9,10 +9,11 @@ import androidx.recyclerview.widget.RecyclerView
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ViewGlobalSearchHeaderBinding import network.loki.messenger.databinding.ViewGlobalSearchHeaderBinding
import network.loki.messenger.databinding.ViewGlobalSearchResultBinding import network.loki.messenger.databinding.ViewGlobalSearchResultBinding
import network.loki.messenger.databinding.ViewGlobalSearchSubheaderBinding
import org.session.libsession.utilities.GroupRecord import org.session.libsession.utilities.GroupRecord
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.search.model.MessageResult import org.thoughtcrime.securesms.search.model.MessageResult
import org.thoughtcrime.securesms.ui.GetString
import java.security.InvalidParameterException import java.security.InvalidParameterException
import org.session.libsession.messaging.contacts.Contact as ContactModel import org.session.libsession.messaging.contacts.Contact as ContactModel
@ -20,7 +21,8 @@ class GlobalSearchAdapter (private val modelCallback: (Model)->Unit): RecyclerVi
companion object { companion object {
const val HEADER_VIEW_TYPE = 0 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() private var data: List<Model> = listOf()
@ -34,21 +36,26 @@ class GlobalSearchAdapter (private val modelCallback: (Model)->Unit): RecyclerVi
} }
override fun getItemViewType(position: Int): Int = 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 getItemCount(): Int = data.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder = override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder =
if (viewType == HEADER_VIEW_TYPE) { when (viewType) {
HeaderView( HEADER_VIEW_TYPE -> HeaderView(
LayoutInflater.from(parent.context) LayoutInflater.from(parent.context).inflate(R.layout.view_global_search_header, parent, false)
.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( override fun onBindViewHolder(
@ -61,10 +68,10 @@ class GlobalSearchAdapter (private val modelCallback: (Model)->Unit): RecyclerVi
holder.bindPayload(newUpdateQuery, data[position]) holder.bindPayload(newUpdateQuery, data[position])
return return
} }
if (holder is HeaderView) { when (holder) {
holder.bind(data[position] as Model.Header) is HeaderView -> holder.bind(data[position] as Model.Header)
} else if (holder is ContentView) { is SubHeaderView -> holder.bind(data[position] as Model.SubHeader)
holder.bind(query.orEmpty(), data[position]) 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) val binding = ViewGlobalSearchHeaderBinding.bind(view)
fun bind(header: Model.Header) { 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.Contact -> bindModel(query, model)
is Model.Message -> bindModel(query, model) is Model.Message -> bindModel(query, model)
is Model.SavedMessages -> bindModel(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) } binding.root.setOnClickListener { modelCallback(model) }
} }
@ -116,11 +132,17 @@ class GlobalSearchAdapter (private val modelCallback: (Model)->Unit): RecyclerVi
) )
sealed class Model { 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 SavedMessages(val currentUserPublicKey: String): Model()
data class Contact(val contact: ContactModel) : Model() data class Contact(val contact: ContactModel): Model()
data class GroupConversation(val groupRecord: GroupRecord) : Model() data class GroupConversation(val groupRecord: GroupRecord): Model()
data class Message(val messageResult: MessageResult, val unread: Int) : Model() data class Message(val messageResult: MessageResult, val unread: Int): Model()
} }
} }

View File

@ -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.Header
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Message 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.SavedMessages
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.SubHeader
import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.SearchUtil import org.thoughtcrime.securesms.util.SearchUtil
import java.util.Locale import java.util.Locale
@ -78,6 +79,7 @@ fun ContentView.bindQuery(query: String, model: GlobalSearchAdapter.Model) {
binding.searchResultSubtitle.text = getHighlight(query, membersString) binding.searchResultSubtitle.text = getHighlight(query, membersString)
} }
is Header, // do nothing for header is Header, // do nothing for header
is SubHeader, // do nothing for subheader
is SavedMessages -> Unit // do nothing for saved messages (displays note to self) is SavedMessages -> Unit // do nothing for saved messages (displays note to self)
} }
} }

View File

@ -2,33 +2,25 @@ package org.thoughtcrime.securesms.home.search
import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.utilities.GroupRecord 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.MessageResult
import org.thoughtcrime.securesms.search.model.SearchResult import org.thoughtcrime.securesms.search.model.SearchResult
data class GlobalSearchResult( data class GlobalSearchResult(
val query: String, val query: String,
val contacts: List<Contact>, val contacts: List<Contact> = emptyList(),
val threads: List<GroupRecord>, val threads: List<GroupRecord> = emptyList(),
val messages: List<MessageResult> val messages: List<MessageResult> = emptyList()
) { ) {
val isEmpty: Boolean val isEmpty: Boolean
get() = contacts.isEmpty() && threads.isEmpty() && messages.isEmpty() get() = contacts.isEmpty() && threads.isEmpty() && messages.isEmpty()
companion object { companion object {
val EMPTY = GlobalSearchResult("")
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)
}
} }
} }
fun SearchResult.toGlobalSearchResult(): GlobalSearchResult = try {
GlobalSearchResult(query, contacts.toList(), conversations.toList(), messages.toList())
} finally {
close()
}

View File

@ -13,14 +13,18 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.session.libsession.utilities.truncateIdForDisplay
import org.session.libsignal.utilities.SettableFuture import org.session.libsignal.utilities.SettableFuture
import org.thoughtcrime.securesms.database.SessionContactDatabase
import org.thoughtcrime.securesms.search.SearchRepository import org.thoughtcrime.securesms.search.SearchRepository
import org.thoughtcrime.securesms.search.model.SearchResult import org.thoughtcrime.securesms.search.model.SearchResult
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class GlobalSearchViewModel @Inject constructor(private val searchRepository: SearchRepository) : ViewModel() { class GlobalSearchViewModel @Inject constructor(
private val searchRepository: SearchRepository,
) : ViewModel() {
private val executor = viewModelScope + SupervisorJob() private val executor = viewModelScope + SupervisorJob()
@ -36,32 +40,27 @@ class GlobalSearchViewModel @Inject constructor(private val searchRepository: Se
} }
init { init {
// _queryText.buffer(onBufferOverflow = BufferOverflow.DROP_OLDEST)
_queryText .mapLatest { query ->
.buffer(onBufferOverflow = BufferOverflow.DROP_OLDEST) // User input delay in case we get a new query within a few hundred ms this
.mapLatest { query -> // coroutine will be cancelled and the expensive query will not be run.
// Early exit on empty search query delay(300)
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)
val settableFuture = SettableFuture<SearchResult>() if (query.trim().isEmpty()) {
searchRepository.query(query.toString(), settableFuture::set) GlobalSearchResult(query.toString(), searchRepository.queryContacts("05").first.toList())
try { } else {
// search repository doesn't play nicely with suspend functions (yet) val settableFuture = SettableFuture<SearchResult>()
settableFuture.get(10_000, TimeUnit.MILLISECONDS) searchRepository.query(query.toString(), settableFuture::set)
} catch (e: Exception) { try {
SearchResult.EMPTY // 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 -> }.onEach { result ->
// update the latest _result value // update the latest _result value
_result.value = GlobalSearchResult.from(result) _result.value = result
} }.launchIn(executor)
.launchIn(executor)
} }
} }

View File

@ -72,10 +72,6 @@ public class SearchRepository {
public void query(@NonNull String query, @NonNull Callback<SearchResult> callback) { public void query(@NonNull String query, @NonNull Callback<SearchResult> callback) {
// If the sanitized search is empty then abort without search // If the sanitized search is empty then abort without search
String cleanQuery = sanitizeQuery(query).trim(); String cleanQuery = sanitizeQuery(query).trim();
if (cleanQuery.isEmpty()) {
callback.onResult(SearchResult.EMPTY);
return;
}
executor.execute(() -> { executor.execute(() -> {
Stopwatch timer = new Stopwatch("FtsQuery"); 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); Cursor contacts = contactDatabase.queryContactsByName(query);
List<Address> contactList = new ArrayList<>(); List<Address> contactList = new ArrayList<>();
List<String> contactStrings = new ArrayList<>(); List<String> contactStrings = new ArrayList<>();

View File

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

View File

@ -2,7 +2,7 @@
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:clickable="true" android:clickable="true"
android:focusable="true" android:focusable="true"
android:background="@drawable/conversation_view_background" android:background="@drawable/conversation_view_search__background"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"

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

View File

@ -36,21 +36,12 @@ class Contact(val sessionID: String) {
/** /**
* The name to display in the UI. For local use only. * The name to display in the UI. For local use only.
*/ */
fun displayName(context: ContactContext): String? { fun displayName(context: ContactContext): String? = nickname ?: when (context) {
nickname?.let { return it } ContactContext.REGULAR -> name
return when (context) { // In open groups, where it's more likely that multiple users have the same name,
ContactContext.REGULAR -> name // we display a bit of the Session ID after a user's display name for added context.
ContactContext.OPEN_GROUP -> { ContactContext.OPEN_GROUP -> name?.let { "$it (${sessionID.take(4)}...${sessionID.takeLast(4)})" }
// 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
}
}
} }
// endregion
enum class ContactContext { enum class ContactContext {
REGULAR, OPEN_GROUP REGULAR, OPEN_GROUP
@ -75,7 +66,6 @@ class Contact(val sessionID: String) {
} }
companion object { companion object {
fun contextForRecipient(recipient: Recipient): ContactContext { fun contextForRecipient(recipient: Recipient): ContactContext {
return if (recipient.isCommunityRecipient) ContactContext.OPEN_GROUP else ContactContext.REGULAR return if (recipient.isCommunityRecipient) ContactContext.OPEN_GROUP else ContactContext.REGULAR
} }

View File

@ -383,3 +383,9 @@ fun <T, K: Any, V: Any> Iterable<T>.associateByNotNull(
this[key] = value 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
}