diff --git a/app/src/main/java/io/xxlabs/messenger/data/room/dao/ContactsDao.kt b/app/src/main/java/io/xxlabs/messenger/data/room/dao/ContactsDao.kt index 22f51acbd00c9cba5c4c8d7ed5b21fd1d83197da..b3ab9ba44e9af59c52ccbf075551e030ea1efc19 100644 --- a/app/src/main/java/io/xxlabs/messenger/data/room/dao/ContactsDao.kt +++ b/app/src/main/java/io/xxlabs/messenger/data/room/dao/ContactsDao.kt @@ -43,15 +43,6 @@ interface ContactsDao { @Query("SELECT * FROM Contacts WHERE status = :status") fun getAllContactsWithStatusLive(status: Int): LiveData<List<ContactData>> - @Query("SELECT * FROM Contacts WHERE username LIKE :username ORDER BY username") - fun queryAllContactsUsername(username: String): Single<List<ContactData>> - - @Query("SELECT * FROM Contacts WHERE email LIKE :email ORDER BY email") - fun queryAllContactsEmail(email: String): Single<List<ContactData>> - - @Query("SELECT * FROM Contacts WHERE phone LIKE :phone ORDER BY phone") - fun queryAllContactsPhone(phone: String): Single<List<ContactData>> - @Query("SELECT * FROM Contacts WHERE username = :username LIMIT 1") fun queryContactByUsername(username: String): Maybe<ContactData> diff --git a/app/src/main/java/io/xxlabs/messenger/repository/DaoRepository.kt b/app/src/main/java/io/xxlabs/messenger/repository/DaoRepository.kt index 984f129f6b37d6a96d2f39f503501e003cbd14ff..7974c3decce1e20d0f467d5ee64f67ed1bbebc9a 100644 --- a/app/src/main/java/io/xxlabs/messenger/repository/DaoRepository.kt +++ b/app/src/main/java/io/xxlabs/messenger/repository/DaoRepository.kt @@ -406,15 +406,6 @@ class DaoRepository @Inject constructor( } } - fun connectionsUsernameSearch(username: String): Single<List<ContactData>> = - contactsDao.queryAllContactsUsername(username) - - fun connectionsEmailSearch(email: String): Single<List<ContactData>> = - contactsDao.queryAllContactsEmail(email) - - fun connectionsPhoneSearch(phone: String): Single<List<ContactData>> = - contactsDao.queryAllContactsPhone(phone) - companion object { @Volatile private var instance: DaoRepository? = null diff --git a/app/src/main/java/io/xxlabs/messenger/requests/ui/list/adapter/RequestItem.kt b/app/src/main/java/io/xxlabs/messenger/requests/ui/list/adapter/RequestItem.kt index 2a6dd4b1278f82558afb7683b64857b84fc3d9f9..ecac901ecaab0c59ba878fa4cd6dc179d474aef4 100644 --- a/app/src/main/java/io/xxlabs/messenger/requests/ui/list/adapter/RequestItem.kt +++ b/app/src/main/java/io/xxlabs/messenger/requests/ui/list/adapter/RequestItem.kt @@ -21,7 +21,7 @@ sealed class RequestItem(val request: Request) : ItemThumbnail { abstract val subtitle: String? abstract val details: String? - val actionLabel: String? = + open val actionLabel: String? = when (request.requestStatus) { VERIFYING -> appContext().getString(R.string.request_item_action_verifying) VERIFICATION_FAIL -> appContext().getString(R.string.request_item_action_failed_verification) @@ -30,7 +30,7 @@ sealed class RequestItem(val request: Request) : ItemThumbnail { else -> null } - val actionIcon: Int? = + open val actionIcon: Int? = when (request.requestStatus) { VERIFICATION_FAIL -> R.drawable.ic_info_outline_24dp SEND_FAIL, SENT -> R.drawable.ic_retry @@ -38,7 +38,7 @@ sealed class RequestItem(val request: Request) : ItemThumbnail { else -> null } - val actionIconColor: Int? = + open val actionIconColor: Int? = when (request.requestStatus) { VERIFICATION_FAIL -> R.color.accent_danger SEND_FAIL, SENT-> R.color.brand_default @@ -47,7 +47,7 @@ sealed class RequestItem(val request: Request) : ItemThumbnail { } @IdRes - val actionTextStyle: Int? = + open val actionTextStyle: Int? = when (request.requestStatus) { VERIFYING -> R.style.request_item_verifying VERIFICATION_FAIL -> R.style.request_item_error @@ -76,6 +76,27 @@ private fun ContactRequest.getContactInfo(): String? = } } +data class ContactRequestSearchResultItem( + val contactRequest: ContactRequest, + val photo: Bitmap? = null, + val statusText: String = "Request pending", + val statusTextColor: Int = R.color.neutral_weak, + val actionVisible: Boolean = true +) : RequestItem(contactRequest) { + override val subtitle: String = statusText + override val details: String? = null + override val itemPhoto: Bitmap? = photo + override val itemInitials: String = contactRequest.model.initials + override val itemIconRes: Int? = null + + // Request search results always have the "SENT" UI even if it's failed. + // Instead, the failed status is described in the statusText property. + override val actionLabel: String = if (actionVisible) appContext().getString(R.string.request_item_action_retry) else "" + override val actionIcon: Int = R.drawable.ic_retry + override val actionIconColor: Int = R.color.brand_default + override val actionTextStyle: Int = R.style.request_item_retry +} + data class GroupInviteItem( val invite: GroupInvitation, // val membersList: List<MemberItem>, diff --git a/app/src/main/java/io/xxlabs/messenger/requests/ui/list/adapter/RequestItemViewHolder.kt b/app/src/main/java/io/xxlabs/messenger/requests/ui/list/adapter/RequestItemViewHolder.kt index 443f00a3872d701f1866de7bb074f9e8d2b022f0..c6fcb9191c8692f73adb18d2ac220236f29b98bc 100644 --- a/app/src/main/java/io/xxlabs/messenger/requests/ui/list/adapter/RequestItemViewHolder.kt +++ b/app/src/main/java/io/xxlabs/messenger/requests/ui/list/adapter/RequestItemViewHolder.kt @@ -9,6 +9,7 @@ import io.xxlabs.messenger.R import io.xxlabs.messenger.databinding.ListItemEmptyPlaceholderBinding import io.xxlabs.messenger.databinding.ListItemHiddenRequestsToggleBinding import io.xxlabs.messenger.databinding.ListItemRequestBinding +import io.xxlabs.messenger.databinding.ListItemRequestSearchResultBinding import io.xxlabs.messenger.databinding.ListItemSectionDividerBinding import timber.log.Timber import java.io.InvalidObjectException @@ -39,6 +40,31 @@ class RequestViewHolder( } } +/** + * For Connection Requests being shown as search results on the Search screen. + */ +class RequestSearchResultViewHolder( + private val binding: ListItemRequestSearchResultBinding +) : RequestItemViewHolder(binding.root) { + + override fun onBind(ui: RequestItem, listener: RequestItemListener) { + binding.ui = ui as ContactRequestSearchResultItem + binding.listener = listener + listener.markAsSeen(ui) + } + + companion object { + fun create(parent: ViewGroup): RequestSearchResultViewHolder { + val binding = ListItemRequestSearchResultBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + return RequestSearchResultViewHolder(binding) + } + } +} + class ConnectionViewHolder( private val binding: ListItemRequestBinding ) : RequestItemViewHolder(binding.root) { diff --git a/app/src/main/java/io/xxlabs/messenger/requests/ui/list/adapter/RequestsAdapter.kt b/app/src/main/java/io/xxlabs/messenger/requests/ui/list/adapter/RequestsAdapter.kt index 6a97f17dea98ff998c64920dfa041848697b35cf..abfdd264a78328e32c8a2056548625cb47abbdf9 100644 --- a/app/src/main/java/io/xxlabs/messenger/requests/ui/list/adapter/RequestsAdapter.kt +++ b/app/src/main/java/io/xxlabs/messenger/requests/ui/list/adapter/RequestsAdapter.kt @@ -11,8 +11,9 @@ class RequestsAdapter( override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RequestItemViewHolder { return when (ViewType.from(viewType)) { - CONNECTION, SEARCH -> ConnectionViewHolder.create(parent) + CONNECTION, UD_SEARCH_RESULT -> ConnectionViewHolder.create(parent) REQUEST, INVITE -> RequestViewHolder.create(parent) + REQUEST_SEARCH_RESULT -> RequestSearchResultViewHolder.create(parent) PLACEHOLDER -> Placeholder.create(parent) SWITCH -> HiddenRequestToggle.create(parent) DIVIDER -> ConnectionsSectionDivider.create(parent) @@ -35,8 +36,9 @@ class RequestsAdapter( is EmptyPlaceholderItem -> PLACEHOLDER.value is HiddenRequestToggleItem -> SWITCH.value is AcceptedConnectionItem -> CONNECTION.value - is SearchResultItem -> SEARCH.value + is SearchResultItem -> UD_SEARCH_RESULT.value is ConnectionsDividerItem -> DIVIDER.value + is ContactRequestSearchResultItem -> REQUEST_SEARCH_RESULT.value else -> OTHER.value } status + model @@ -49,9 +51,10 @@ class RequestsAdapter( PLACEHOLDER(300), SWITCH(400), CONNECTION(500), - SEARCH(600), + UD_SEARCH_RESULT(600), DIVIDER(700), - OTHER(800); + REQUEST_SEARCH_RESULT(800), + OTHER(900); companion object { fun from(value: Int): ViewType { diff --git a/app/src/main/java/io/xxlabs/messenger/search/UserSearchViewModel.kt b/app/src/main/java/io/xxlabs/messenger/search/UserSearchViewModel.kt index 56bf1d68c87df8401bdcb92b48e66f4b6994ce01..730d2159d629368963eb9deaeda62e28b8559e1a 100644 --- a/app/src/main/java/io/xxlabs/messenger/search/UserSearchViewModel.kt +++ b/app/src/main/java/io/xxlabs/messenger/search/UserSearchViewModel.kt @@ -1,12 +1,17 @@ package io.xxlabs.messenger.search import android.graphics.Bitmap -import android.text.* +import android.text.Editable +import android.text.Spannable +import android.text.SpannableString +import android.text.Spanned import android.text.style.ForegroundColorSpan import androidx.lifecycle.* +import bindings.Fact import io.xxlabs.messenger.R import io.xxlabs.messenger.bindings.wrapper.contact.ContactWrapperBase import io.xxlabs.messenger.data.data.Country +import io.xxlabs.messenger.data.datatype.ContactRequestState import io.xxlabs.messenger.data.datatype.FactType import io.xxlabs.messenger.data.datatype.RequestStatus import io.xxlabs.messenger.data.room.model.ContactData @@ -25,7 +30,8 @@ import io.xxlabs.messenger.ui.dialog.info.createInfoDialog import io.xxlabs.messenger.ui.dialog.info.createTwoButtonDialogUi import io.xxlabs.messenger.ui.main.countrycode.CountrySelectionListener import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber @@ -291,26 +297,33 @@ class UserSearchViewModel @Inject constructor( _udSearchUi.value = searchRunningState viewModelScope.launch { - /* TODO: When the username matches a connections' nickname, search UD too. - Two users can have the same displayName when - one username matches the other nickname. - */ clearPreviousResults(resultsEmitter) - searchConnections(factQuery).run { - if (isNotEmpty()) { - resultsEmitter.emitResults(this) - } else { - null - } - } ?: searchUd(factQuery)?.run { - resultsEmitter.emitResults(listOf(this)) - } ?: resultsEmitter.emitResults(noResultsFor(factQuery)) + val udResult = searchUd(factQuery) + val requestResults = searchRequests(factQuery) + val connectionResults = searchConnections(factQuery) + + val remoteResults = udResult?.let { + listOf(udResult) + requestResults + } ?: requestResults + + val sortedResults = if (remoteResults.isEmpty()) { + connectionResults.ifEmpty { noResultsFor(factQuery) } + } else { + if (connectionResults.isEmpty()) remoteResults + else remoteResults + listOf(ConnectionsDividerItem()) + connectionResults + } + + resultsEmitter.emitResults(sortedResults) } } private fun isValidQuery(factQuery: FactQuery): Boolean { // Prevent users from searching (and possibly requesting) themselves. - return !preferences.userData.contains(factQuery.fact, true) + return with (factQuery.fact) { + this != repo.getStoredUsername() + && this != repo.getStoredEmail() + && this != repo.getStoredPhone() + } } private fun noResultsFor(factQuery: FactQuery): List<RequestItem> = @@ -330,59 +343,109 @@ class UserSearchViewModel @Inject constructor( _udSearchUi.value = searchCompleteState } - private suspend fun searchConnections(factQuery: FactQuery): List<RequestItem> { - return when (factQuery.type) { - FactType.USERNAME -> { - daoRepo.connectionsUsernameSearch(factQuery.fact) - .value() - .asAcceptedConnections() - } - FactType.EMAIL -> { - daoRepo.connectionsEmailSearch(factQuery.fact) - .value() - .asAcceptedConnections() - } - FactType.PHONE -> { - daoRepo.connectionsPhoneSearch(factQuery.fact) - .value() - .asAcceptedConnections() + private suspend fun savedUsers(): List<ContactData> = + daoRepo.getAllContacts().value() + + private fun ContactData.isConnection(): Boolean = + RequestStatus.from(status) == RequestStatus.ACCEPTED + + private fun ContactData.isRequest(): Boolean = !isConnection() + + private suspend fun searchConnections(factQuery: FactQuery): List<RequestItem> = + withContext(Dispatchers.IO) { + when (factQuery.type) { + FactType.USERNAME -> { + savedUsers().filter { + it.isConnection() && it.displayName.contains(factQuery.fact) + }.asConnectionsSearchResult() + } + FactType.EMAIL -> { + savedUsers().filter { + it.isConnection() && it.email.contains(factQuery.fact) + }.asConnectionsSearchResult() + } + FactType.PHONE -> { + savedUsers().filter { + it.isConnection() && it.phone.contains(factQuery.fact) + }.asConnectionsSearchResult() + } + else -> listOf() } - else -> listOf() } - } - private suspend fun List<ContactData>.asAcceptedConnections(): List<RequestItem> { - val requests = filter { - it.status != RequestStatus.ACCEPTED.value - }.toSet() - // Separate into a sublist of accepted connections and requests. - val contacts = this - requests - - // Wrap the requests as SearchResultItems for UI layer. - val requestItems = requests.map { - SearchResultItem( - ContactRequestData(it), - resolveBitmap(it.photo) - ) - }.toMutableList() + private suspend fun searchRequests(factQuery: FactQuery): List<RequestItem> = + withContext(Dispatchers.IO) { + when (factQuery.type) { + FactType.USERNAME -> { + savedUsers().filter { + it.isRequest() && it.displayName.contains(factQuery.fact) + }.asRequestsSearchResult() + } + FactType.EMAIL -> { + savedUsers().filter { + it.isRequest() && it.email.contains(factQuery.fact) + }.asRequestsSearchResult() + } + FactType.PHONE -> { + savedUsers().filter { + it.isRequest() && it.phone.contains(factQuery.fact) + }.asRequestsSearchResult() + } + else -> listOf() + } + } - // Wrap the connections as AcceptedConnectionItems for UI layer. - val contactItems = contacts.map { + private suspend fun List<ContactData>.asConnectionsSearchResult(): List<RequestItem> = + map { val requestData = ContactRequestData(it) AcceptedConnectionItem( requestData, resolveBitmap(it.photo) ) } - val localResults = if (contacts.isNotEmpty()) { - requestItems + ConnectionsDividerItem() + contactItems - } else { - requestItems + + private suspend fun List<ContactData>.asRequestsSearchResult(): List<RequestItem> = + map { + ContactRequestSearchResultItem( + contactRequest = ContactRequestData(it), + photo = resolveBitmap(it.photo), + statusText = it.statusText(), + statusTextColor = it.statusTextColor(), + actionVisible= it.actionVisible() + ) + } + + private fun ContactData.statusText(): String { + return when (RequestStatus.from(status)) { + RequestStatus.SENT, + RequestStatus.VERIFIED, + RequestStatus.RESET_SENT -> "Request pending" + + RequestStatus.SEND_FAIL, + RequestStatus.CONFIRM_FAIL, + RequestStatus.VERIFICATION_FAIL, + RequestStatus.RESET_FAIL -> "Request failed" + + else -> "" + } + } + + private fun ContactData.statusTextColor(): Int { + return when (RequestStatus.from(status)) { + RequestStatus.SEND_FAIL, + RequestStatus.CONFIRM_FAIL, + RequestStatus.VERIFICATION_FAIL, + RequestStatus.RESET_FAIL -> R.color.accent_danger + + else -> R.color.neutral_weak + } + } + + private fun ContactData.actionVisible(): Boolean { + return when (RequestStatus.from(status)) { + RequestStatus.VERIFIED -> false + else -> true } - Timber.d("RequestItems: ${requestItems.size} entries") - Timber.d("ContactItems: ${contactItems.size} entries") - Timber.d("LocalResults: ${localResults.size} entries") - return localResults } private suspend fun resolveBitmap(data: ByteArray?): Bitmap? = withContext(Dispatchers.IO) { @@ -394,7 +457,9 @@ class UserSearchViewModel @Inject constructor( val udResult = repo.searchUd(factQuery.fact, factQuery.type).value() udResult.second?.let { // Error message if (it.isNotEmpty()) { - showToast(it) + if (!it.contains("no results found", true)) { + showToast(it) + } noResultPlaceholder(factQuery) } else { // Search result udResult.first?.asSearchResult() ?: noResultPlaceholder(factQuery) diff --git a/app/src/main/res/layout/list_item_request_search_result.xml b/app/src/main/res/layout/list_item_request_search_result.xml new file mode 100644 index 0000000000000000000000000000000000000000..6dcf6c880a13c4dc0eab50d1f8818468c0295fa9 --- /dev/null +++ b/app/src/main/res/layout/list_item_request_search_result.xml @@ -0,0 +1,86 @@ +<?xml version="1.0" encoding="utf-8"?> +<layout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools"> + + <data> + <import type="io.xxlabs.messenger.ui.main.chat.DrawablePosition"/> + <variable + name="ui" + type="io.xxlabs.messenger.requests.ui.list.adapter.ContactRequestSearchResultItem" /> + <variable + name="listener" + type="io.xxlabs.messenger.requests.ui.list.adapter.RequestItemListener" /> + </data> + + <androidx.constraintlayout.widget.ConstraintLayout + android:id="@+id/requestItemLayout" + android:padding="16dp" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:onClick="@{() -> listener.onItemClicked(ui)}" > + + <include + android:id="@+id/requestProfilePhoto" + layout="@layout/component_item_thumbnail" + android:layout_width="@dimen/spacing_42" + android:layout_height="@dimen/spacing_42" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:ui="@{ui}" /> + + <TextView + android:id="@+id/requestTitle" + style="@style/request_item_header" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginBottom="4dp" + android:text="@{ui.title}" + app:layout_constraintBottom_toTopOf="@+id/requestSubtitle" + app:layout_constraintEnd_toStartOf="@id/requestAction" + app:layout_constraintHorizontal_bias="0.0" + app:layout_constraintStart_toEndOf="@id/requestProfilePhoto" + app:layout_constraintTop_toTopOf="@id/requestProfilePhoto" + tools:text="bartender007" /> + + <TextView + android:id="@+id/requestSubtitle" + style="@style/request_item_subheader" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="@{ui.subtitle}" + android:visibility="@{ui.subtitle}" + android:textColor="@{ui.statusTextColor}" + app:layout_constraintEnd_toStartOf="@id/requestAction" + app:layout_constraintHorizontal_bias="0.0" + app:layout_constraintStart_toEndOf="@id/requestProfilePhoto" + app:layout_constraintTop_toBottomOf="@id/requestTitle" + tools:text="Request pending" + tools:textColor="@color/neutral_weak"/> + + <io.xxlabs.messenger.support.view.SingleClickTextView + android:id="@+id/requestAction" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="@{ui.actionLabel}" + android:padding="12dp" + android:layout_marginHorizontal="0dp" + android:drawablePadding="4dp" + android:onClick="@{() -> listener.onActionClicked(ui)}" + android:visibility="@{ui.actionLabel}" + android:singleLine="false" + android:gravity="center_vertical|end" + app:actionIcon="@{ui.actionIcon}" + app:iconColor="@{ui.actionIconColor}" + app:iconPosition="@{DrawablePosition.START}" + app:customStyle="@{ui.actionTextStyle}" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintBottom_toBottomOf="parent" + tools:drawableLeft="@drawable/ic_retry" + tools:text="Resend" + style="@style/request_item_subheader"/> + </androidx.constraintlayout.widget.ConstraintLayout> +</layout> \ No newline at end of file