From d59aaa761141ab38f15203cb1df21e9d46a45071 Mon Sep 17 00:00:00 2001 From: Valere <valeref@matrix.org> Date: Tue, 21 Sep 2021 16:57:55 +0200 Subject: [PATCH] Support entering mail in user invite screen --- changelog.d/4042.bugfix | 1 + .../org/matrix/android/sdk/rx/RxSession.kt | 5 + .../identity/DefaultIdentityService.kt | 20 +-- .../spaces/share/ShareSpaceBottomSheet.kt | 6 - .../userdirectory/FoundThreePidItem.kt | 58 ++++++++ .../features/userdirectory/UserListAction.kt | 1 + .../userdirectory/UserListController.kt | 119 ++++++++++++++++ .../userdirectory/UserListFragment.kt | 28 +++- .../userdirectory/UserListViewModel.kt | 128 ++++++++++++++++-- .../userdirectory/UserListViewState.kt | 2 + .../res/layout/bottom_sheet_space_invite.xml | 18 +-- .../main/res/layout/item_invite_by_mail.xml | 80 +++++++++++ vector/src/main/res/values/strings.xml | 10 ++ 13 files changed, 440 insertions(+), 36 deletions(-) create mode 100644 changelog.d/4042.bugfix create mode 100644 vector/src/main/java/im/vector/app/features/userdirectory/FoundThreePidItem.kt create mode 100644 vector/src/main/res/layout/item_invite_by_mail.xml diff --git a/changelog.d/4042.bugfix b/changelog.d/4042.bugfix new file mode 100644 index 0000000000..4a50bc5884 --- /dev/null +++ b/changelog.d/4042.bugfix @@ -0,0 +1 @@ +Private space invite bottomsheet only offering inviting by username not by email \ No newline at end of file diff --git a/matrix-sdk-android-rx/src/main/java/org/matrix/android/sdk/rx/RxSession.kt b/matrix-sdk-android-rx/src/main/java/org/matrix/android/sdk/rx/RxSession.kt index 58fb760ff5..47203816b4 100644 --- a/matrix-sdk-android-rx/src/main/java/org/matrix/android/sdk/rx/RxSession.kt +++ b/matrix-sdk-android-rx/src/main/java/org/matrix/android/sdk/rx/RxSession.kt @@ -32,6 +32,7 @@ import org.matrix.android.sdk.api.session.crypto.crosssigning.SELF_SIGNING_KEY_S import org.matrix.android.sdk.api.session.crypto.crosssigning.USER_SIGNING_KEY_SSSS_NAME import org.matrix.android.sdk.api.session.group.GroupSummaryQueryParams import org.matrix.android.sdk.api.session.group.model.GroupSummary +import org.matrix.android.sdk.api.session.identity.FoundThreePid import org.matrix.android.sdk.api.session.identity.ThreePid import org.matrix.android.sdk.api.session.pushers.Pusher import org.matrix.android.sdk.api.session.room.RoomSummaryQueryParams @@ -239,6 +240,10 @@ class RxSession(private val session: Session) { ) .distinctUntilChanged() } + + fun lookupThreePid(threePid: ThreePid): Single<Optional<FoundThreePid>> = rxSingle { + session.identityService().lookUp(listOf(threePid)).firstOrNull().toOptional() + } } fun Session.rx(): RxSession { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/DefaultIdentityService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/DefaultIdentityService.kt index fdb6caf53f..a4ad48038f 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/DefaultIdentityService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/DefaultIdentityService.kt @@ -20,10 +20,16 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry import dagger.Lazy +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient import org.matrix.android.sdk.api.auth.data.SessionParams +import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.failure.Failure import org.matrix.android.sdk.api.failure.MatrixError +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.SessionLifecycleObserver +import org.matrix.android.sdk.api.session.accountdata.UserAccountDataTypes import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilitiesService import org.matrix.android.sdk.api.session.identity.FoundThreePid @@ -36,23 +42,17 @@ import org.matrix.android.sdk.internal.di.AuthenticatedIdentity import org.matrix.android.sdk.internal.di.UnauthenticatedWithCertificate import org.matrix.android.sdk.internal.extensions.observeNotNull import org.matrix.android.sdk.internal.network.RetrofitFactory -import org.matrix.android.sdk.api.session.SessionLifecycleObserver import org.matrix.android.sdk.internal.session.SessionScope import org.matrix.android.sdk.internal.session.identity.data.IdentityStore +import org.matrix.android.sdk.internal.session.identity.model.SignInvitationResult import org.matrix.android.sdk.internal.session.openid.GetOpenIdTokenTask import org.matrix.android.sdk.internal.session.profile.BindThreePidsTask import org.matrix.android.sdk.internal.session.profile.UnbindThreePidsTask import org.matrix.android.sdk.internal.session.sync.model.accountdata.IdentityServerContent -import org.matrix.android.sdk.api.session.accountdata.UserAccountDataTypes -import org.matrix.android.sdk.internal.session.user.accountdata.UserAccountDataDataSource import org.matrix.android.sdk.internal.session.user.accountdata.UpdateUserAccountDataTask +import org.matrix.android.sdk.internal.session.user.accountdata.UserAccountDataDataSource import org.matrix.android.sdk.internal.util.MatrixCoroutineDispatchers import org.matrix.android.sdk.internal.util.ensureProtocol -import kotlinx.coroutines.withContext -import okhttp3.OkHttpClient -import org.matrix.android.sdk.api.extensions.orFalse -import org.matrix.android.sdk.api.session.Session -import org.matrix.android.sdk.internal.session.identity.model.SignInvitationResult import timber.log.Timber import javax.inject.Inject import javax.net.ssl.HttpsURLConnection @@ -227,9 +227,13 @@ internal class DefaultIdentityService @Inject constructor( override fun setUserConsent(newValue: Boolean) { identityStore.setUserConsent(newValue) + // notify listeners + listeners.toList().forEach { tryOrNull { it.onIdentityServerChange() } } } override suspend fun lookUp(threePids: List<ThreePid>): List<FoundThreePid> { + if (getCurrentIdentityServerUrl() == null) throw IdentityServiceError.NoIdentityServerConfigured + if (!getUserConsent()) { throw IdentityServiceError.UserConsentNotProvided } diff --git a/vector/src/main/java/im/vector/app/features/spaces/share/ShareSpaceBottomSheet.kt b/vector/src/main/java/im/vector/app/features/spaces/share/ShareSpaceBottomSheet.kt index 4289af7b3b..bd69de0d95 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/share/ShareSpaceBottomSheet.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/share/ShareSpaceBottomSheet.kt @@ -73,7 +73,6 @@ class ShareSpaceBottomSheet : VectorBaseBottomSheetDialogFragment<BottomSheetSpa views.descriptionText.setTextOrHide(null) } - views.inviteByMailButton.isVisible = false // not yet implemented views.inviteByLinkButton.isVisible = state.canShareLink views.inviteByMxidButton.isVisible = state.canInviteByMxId } @@ -81,11 +80,6 @@ class ShareSpaceBottomSheet : VectorBaseBottomSheetDialogFragment<BottomSheetSpa override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - // XXX enable back when supported - views.inviteByMailButton.isVisible = false - views.inviteByMailButton.debouncedClicks { - } - views.inviteByMxidButton.debouncedClicks { viewModel.handle(ShareSpaceAction.InviteByMxId) } diff --git a/vector/src/main/java/im/vector/app/features/userdirectory/FoundThreePidItem.kt b/vector/src/main/java/im/vector/app/features/userdirectory/FoundThreePidItem.kt new file mode 100644 index 0000000000..ff61f76d58 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/userdirectory/FoundThreePidItem.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.userdirectory + +import android.widget.ImageView +import android.widget.TextView +import androidx.core.view.isVisible +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import im.vector.app.R +import im.vector.app.core.epoxy.ClickListener +import im.vector.app.core.epoxy.VectorEpoxyHolder +import im.vector.app.core.epoxy.VectorEpoxyModel +import im.vector.app.features.home.AvatarRenderer + +@EpoxyModelClass(layout = R.layout.item_invite_by_mail) +abstract class FoundThreePidItem : VectorEpoxyModel<FoundThreePidItem.Holder>() { + + @EpoxyAttribute lateinit var avatarRenderer: AvatarRenderer + @EpoxyAttribute lateinit var foundItem: ThreePidUser + @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) var clickListener: ClickListener? = null + @EpoxyAttribute var selected: Boolean = false + + override fun bind(holder: Holder) { + super.bind(holder) + holder.itemTitleText.text = foundItem.email + holder.checkedImageView.isVisible = false + holder.avatarImageView.isVisible = true + holder.view.setOnClickListener(clickListener) + if (selected) { + holder.checkedImageView.isVisible = true + holder.avatarImageView.isVisible = false + } else { + holder.checkedImageView.isVisible = false + holder.avatarImageView.isVisible = true + } + } + + class Holder : VectorEpoxyHolder() { + val itemTitleText by bind<TextView>(R.id.itemTitle) + val avatarImageView by bind<ImageView>(R.id.itemAvatar) + val checkedImageView by bind<ImageView>(R.id.itemAvatarChecked) + } +} diff --git a/vector/src/main/java/im/vector/app/features/userdirectory/UserListAction.kt b/vector/src/main/java/im/vector/app/features/userdirectory/UserListAction.kt index 7835232b09..83829c1119 100644 --- a/vector/src/main/java/im/vector/app/features/userdirectory/UserListAction.kt +++ b/vector/src/main/java/im/vector/app/features/userdirectory/UserListAction.kt @@ -24,4 +24,5 @@ sealed class UserListAction : VectorViewModelAction { data class AddPendingSelection(val pendingSelection: PendingSelection) : UserListAction() data class RemovePendingSelection(val pendingSelection: PendingSelection) : UserListAction() object ComputeMatrixToLinkForSharing : UserListAction() + data class UpdateUserConsent(val consent: Boolean) : UserListAction() } diff --git a/vector/src/main/java/im/vector/app/features/userdirectory/UserListController.kt b/vector/src/main/java/im/vector/app/features/userdirectory/UserListController.kt index bc2ef1f694..fbdaa22c9b 100644 --- a/vector/src/main/java/im/vector/app/features/userdirectory/UserListController.kt +++ b/vector/src/main/java/im/vector/app/features/userdirectory/UserListController.kt @@ -26,9 +26,13 @@ import im.vector.app.core.epoxy.errorWithRetryItem import im.vector.app.core.epoxy.loadingItem import im.vector.app.core.epoxy.noResultItem import im.vector.app.core.error.ErrorFormatter +import im.vector.app.core.resources.ColorProvider import im.vector.app.core.resources.StringProvider +import im.vector.app.core.ui.list.genericPillItem import im.vector.app.features.home.AvatarRenderer +import me.gujun.android.span.span import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.identity.IdentityServiceError import org.matrix.android.sdk.api.session.identity.ThreePid import org.matrix.android.sdk.api.session.user.model.User import org.matrix.android.sdk.api.util.toMatrixItem @@ -37,6 +41,7 @@ import javax.inject.Inject class UserListController @Inject constructor(private val session: Session, private val avatarRenderer: AvatarRenderer, private val stringProvider: StringProvider, + private val colorProvider: ColorProvider, private val errorFormatter: ErrorFormatter) : EpoxyController() { private var state: UserListViewState? = null @@ -86,6 +91,118 @@ class UserListController @Inject constructor(private val session: Session, } } + when (val matchingEmail = currentState.matchingEmail) { + is Success -> { + userListHeaderItem { + id("is_matching") + header(host.stringProvider.getString(R.string.discovery_section, currentState.configuredIdentityServer ?: "")) + } + val invoke = matchingEmail() + val isSelected = currentState.pendingSelections.indexOfFirst { pendingSelection -> + when (pendingSelection) { + is PendingSelection.ThreePidPendingSelection -> { + when (pendingSelection.threePid) { + is ThreePid.Email -> pendingSelection.threePid.email == invoke?.email + is ThreePid.Msisdn -> false + } + } + is PendingSelection.UserPendingSelection -> { + invoke?.user != null && invoke.user.userId == pendingSelection.user.userId + } + } + } != -1 + if (invoke?.user == null) { + foundThreePidItem { + id("email_${invoke?.email}") + foundItem(invoke!!) + selected(isSelected) + clickListener { + host.callback?.onThreePidClick(ThreePid.Email(invoke.email)) + } + } + } else { + userDirectoryUserItem { + id(invoke.user.userId) + selected(isSelected) + matrixItem(invoke.user.toMatrixItem().let { + it.copy( + displayName = "${it.displayName} [${invoke.email}]" + ) + }) + avatarRenderer(host.avatarRenderer) + clickListener { + host.callback?.onItemClick(invoke.user) + } + } + } + } + is Fail -> { + when (matchingEmail.error) { + is IdentityServiceError.UserConsentNotProvided -> { + genericPillItem { + id("consent_not_given") + text( + span { + span { + text = host.stringProvider.getString(R.string.settings_discovery_consent_notice_off) + } + +"\n" + span { + text = host.stringProvider.getString(R.string.settings_discovery_consent_action_give_consent) + textStyle = "bold" + textColor = host.colorProvider.getColorFromAttribute(R.attr.colorPrimary) + } + } + ) + itemClickAction { + host.callback?.giveIdentityServerConsent() + } + } + } + is IdentityServiceError.NoIdentityServerConfigured -> { + genericPillItem { + id("no_IDS") + imageRes(R.drawable.ic_info) + text( + span { + span { + text = host.stringProvider.getString(R.string.finish_setting_up_discovery) + textColor = host.colorProvider.getColorFromAttribute(R.attr.vctr_content_primary) + } + +"\n" + span { + text = host.stringProvider.getString(R.string.discovery_invite) + textColor = host.colorProvider.getColorFromAttribute(R.attr.vctr_content_secondary) + } + +"\n" + span { + text = host.stringProvider.getString(R.string.finish_setup) + textStyle = "bold" + textColor = host.colorProvider.getColorFromAttribute(R.attr.colorPrimary) + } + } + ) + itemClickAction { + host.callback?.onSetupDiscovery() + } + } + } + } + } + is Loading -> { + userListHeaderItem { + id("is_matching") + header(host.stringProvider.getString(R.string.discovery_section, currentState.configuredIdentityServer ?: "")) + } + loadingItem { + id("is_loading") + } + } + else -> { + // nop + } + } + when (currentState.knownUsers) { is Uninitialized -> renderEmptyState() is Loading -> renderLoading() @@ -196,5 +313,7 @@ class UserListController @Inject constructor(private val session: Session, fun onItemClick(user: User) fun onMatrixIdClick(matrixId: String) fun onThreePidClick(threePid: ThreePid) + fun onSetupDiscovery() + fun giveIdentityServerConsent() } } diff --git a/vector/src/main/java/im/vector/app/features/userdirectory/UserListFragment.kt b/vector/src/main/java/im/vector/app/features/userdirectory/UserListFragment.kt index 6e6df7a7aa..9150511c15 100644 --- a/vector/src/main/java/im/vector/app/features/userdirectory/UserListFragment.kt +++ b/vector/src/main/java/im/vector/app/features/userdirectory/UserListFragment.kt @@ -31,6 +31,7 @@ import com.airbnb.mvrx.args import com.airbnb.mvrx.fragmentViewModel import com.airbnb.mvrx.withState import com.google.android.material.chip.Chip +import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.jakewharton.rxbinding3.widget.textChanges import im.vector.app.R import im.vector.app.core.extensions.cleanup @@ -42,6 +43,7 @@ import im.vector.app.core.utils.DimensionConverter import im.vector.app.core.utils.startSharePlainTextIntent import im.vector.app.databinding.FragmentUserListBinding import im.vector.app.features.homeserver.HomeServerCapabilitiesViewModel +import im.vector.app.features.settings.VectorSettingsActivity import org.matrix.android.sdk.api.session.identity.ThreePid import org.matrix.android.sdk.api.session.user.model.User @@ -65,6 +67,10 @@ class UserListFragment @Inject constructor( override fun getMenuRes() = args.menuResId + override fun onResume() { + super.onResume() + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) sharedActionViewModel = activityViewModelProvider.get(UserListSharedActionViewModel::class.java) @@ -131,7 +137,7 @@ class UserListFragment @Inject constructor( private fun setupSearchView() { withState(viewModel) { - views.userListSearch.hint = getString(R.string.user_directory_search_hint) + views.userListSearch.hint = getString(R.string.user_directory_search_hint_2) } views.userListSearch .textChanges() @@ -217,6 +223,26 @@ class UserListFragment @Inject constructor( viewModel.handle(UserListAction.AddPendingSelection(PendingSelection.ThreePidPendingSelection(threePid))) } + override fun onSetupDiscovery() { + navigator.openSettings( + requireContext(), + VectorSettingsActivity.EXTRA_DIRECT_ACCESS_DISCOVERY_SETTINGS + ) + } + + override fun giveIdentityServerConsent() { + withState(viewModel) { state -> + MaterialAlertDialogBuilder(requireActivity()) + .setTitle(R.string.identity_server_consent_dialog_title) + .setMessage(getString(R.string.identity_server_consent_dialog_content, state.configuredIdentityServer ?: "")) + .setPositiveButton(R.string.yes) { _, _ -> + viewModel.handle(UserListAction.UpdateUserConsent(true)) + } + .setNegativeButton(R.string.no, null) + .show() + } + } + override fun onUseQRCode() { view?.hideKeyboard() sharedActionViewModel.post(UserListSharedAction.AddByQrCode) diff --git a/vector/src/main/java/im/vector/app/features/userdirectory/UserListViewModel.kt b/vector/src/main/java/im/vector/app/features/userdirectory/UserListViewModel.kt index 5d5247ec06..8c20fc35b3 100644 --- a/vector/src/main/java/im/vector/app/features/userdirectory/UserListViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/userdirectory/UserListViewModel.kt @@ -16,30 +16,43 @@ package im.vector.app.features.userdirectory +import androidx.lifecycle.viewModelScope import com.airbnb.mvrx.ActivityViewModelContext import com.airbnb.mvrx.FragmentViewModelContext import com.airbnb.mvrx.MvRxViewModelFactory +import com.airbnb.mvrx.Uninitialized import com.airbnb.mvrx.ViewModelContext import com.jakewharton.rxrelay2.BehaviorRelay import dagger.assisted.Assisted -import dagger.assisted.AssistedInject import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import im.vector.app.core.extensions.exhaustive +import im.vector.app.core.extensions.isEmail import im.vector.app.core.extensions.toggle import im.vector.app.core.platform.VectorViewModel import io.reactivex.Single import io.reactivex.android.schedulers.AndroidSchedulers +import kotlinx.coroutines.launch import org.matrix.android.sdk.api.MatrixPatterns import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.identity.IdentityServiceListener +import org.matrix.android.sdk.api.session.identity.ThreePid import org.matrix.android.sdk.api.session.profile.ProfileService import org.matrix.android.sdk.api.session.user.model.User import org.matrix.android.sdk.api.util.toMatrixItem import org.matrix.android.sdk.api.util.toOptional import org.matrix.android.sdk.rx.rx +import timber.log.Timber import java.util.concurrent.TimeUnit private typealias KnownUsersSearch = String private typealias DirectoryUsersSearch = String +private typealias IdentityServerUserSearch = String + +data class ThreePidUser( + val email: String, + val user: User? +) class UserListViewModel @AssistedInject constructor(@Assisted initialState: UserListViewState, private val session: Session) @@ -47,6 +60,7 @@ class UserListViewModel @AssistedInject constructor(@Assisted initialState: User private val knownUsersSearch = BehaviorRelay.create<KnownUsersSearch>() private val directoryUsersSearch = BehaviorRelay.create<DirectoryUsersSearch>() + private val identityServerUsersSearch = BehaviorRelay.create<String>() @AssistedFactory interface Factory { @@ -64,24 +78,77 @@ class UserListViewModel @AssistedInject constructor(@Assisted initialState: User } } + private val identityServerListener = object : IdentityServiceListener { + override fun onIdentityServerChange() { + withState { + identityServerUsersSearch.accept(it.searchTerm) + setState { + copy( + configuredIdentityServer = cleanISURL(session.identityService().getCurrentIdentityServerUrl()) + ) + } + } + } + } + init { observeUsers() + setState { + copy( + configuredIdentityServer = cleanISURL(session.identityService().getCurrentIdentityServerUrl()) + ) + } + session.identityService().addListener(identityServerListener) + } + + private fun cleanISURL(url: String?): String? { + return if (url?.startsWith("https://") == true) { + url.substring("https://".length) + } else url + } + + override fun onCleared() { + session.identityService().removeListener(identityServerListener) + super.onCleared() } override fun handle(action: UserListAction) { when (action) { - is UserListAction.SearchUsers -> handleSearchUsers(action.value) - is UserListAction.ClearSearchUsers -> handleClearSearchUsers() - is UserListAction.AddPendingSelection -> handleSelectUser(action) - is UserListAction.RemovePendingSelection -> handleRemoveSelectedUser(action) + is UserListAction.SearchUsers -> handleSearchUsers(action.value) + is UserListAction.ClearSearchUsers -> handleClearSearchUsers() + is UserListAction.AddPendingSelection -> handleSelectUser(action) + is UserListAction.RemovePendingSelection -> handleRemoveSelectedUser(action) UserListAction.ComputeMatrixToLinkForSharing -> handleShareMyMatrixToLink() + is UserListAction.UpdateUserConsent -> handleISUpdateConsent(action) }.exhaustive } + private fun handleISUpdateConsent(action: UserListAction.UpdateUserConsent) { + viewModelScope.launch { + try { + session.identityService().setUserConsent(action.consent) + } catch (failure: Throwable) { + Timber.d("Failed to update IS consent", failure) + } + } + } + private fun handleSearchUsers(searchTerm: String) { setState { - copy(searchTerm = searchTerm) + copy( + searchTerm = searchTerm + ) } + if (searchTerm.isEmail().not()) { + // if it's not an email reset to uninitialized + // because the flow won't be triggered and result would stay + setState { + copy( + matchingEmail = Uninitialized + ) + } + } + identityServerUsersSearch.accept(searchTerm) knownUsersSearch.accept(searchTerm) directoryUsersSearch.accept(searchTerm) } @@ -95,12 +162,47 @@ class UserListViewModel @AssistedInject constructor(@Assisted initialState: User private fun handleClearSearchUsers() { knownUsersSearch.accept("") directoryUsersSearch.accept("") + identityServerUsersSearch.accept("") setState { copy(searchTerm = "") } } private fun observeUsers() = withState { state -> + + identityServerUsersSearch + .filter { it.isEmail() } + .throttleLast(300, TimeUnit.MILLISECONDS) + .switchMapSingle { search -> + val rx = session.rx() + val stream = + rx.lookupThreePid(ThreePid.Email(search)).flatMap { + it.getOrNull()?.let { foundThreePid -> + rx.getProfileInfo(foundThreePid.matrixId) + .map { json -> + ThreePidUser( + email = search, + user = User( + userId = foundThreePid.matrixId, + displayName = json[ProfileService.DISPLAY_NAME_KEY] as? String, + avatarUrl = json[ProfileService.AVATAR_URL_KEY] as? String + ) + ).toOptional() + } + .onErrorResumeNext { + Single.just(ThreePidUser(email = search, user = User(foundThreePid.matrixId)).toOptional()) + } + } ?: Single.just(ThreePidUser(email = search, user = null).toOptional()) + } + .map { it.getOrNull() } + + stream.toAsync { + copy(matchingEmail = it) + } + } + .subscribe() + .disposeOnClear() + knownUsersSearch .throttleLast(300, TimeUnit.MILLISECONDS) .observeOn(AndroidSchedulers.mainThread()) @@ -136,14 +238,16 @@ class UserListViewModel @AssistedInject constructor(@Assisted initialState: User avatarUrl = json[ProfileService.AVATAR_URL_KEY] as? String ).toOptional() } - .onErrorReturn { + .onErrorResumeNext { // Profile API can be restricted and doesn't have to return result. // In this case allow inviting valid user ids. - User( - userId = search, - displayName = null, - avatarUrl = null - ).toOptional() + Single.just( + User( + userId = search, + displayName = null, + avatarUrl = null + ).toOptional() + ) } Single.zip( diff --git a/vector/src/main/java/im/vector/app/features/userdirectory/UserListViewState.kt b/vector/src/main/java/im/vector/app/features/userdirectory/UserListViewState.kt index f1cbbd3b9d..b66d36c5f0 100644 --- a/vector/src/main/java/im/vector/app/features/userdirectory/UserListViewState.kt +++ b/vector/src/main/java/im/vector/app/features/userdirectory/UserListViewState.kt @@ -27,10 +27,12 @@ data class UserListViewState( val excludedUserIds: Set<String>? = null, val knownUsers: Async<PagedList<User>> = Uninitialized, val directoryUsers: Async<List<User>> = Uninitialized, + val matchingEmail: Async<ThreePidUser?> = Uninitialized, val filteredMappedContacts: List<MappedContact> = emptyList(), val pendingSelections: Set<PendingSelection> = emptySet(), val searchTerm: String = "", val singleSelection: Boolean, + val configuredIdentityServer: String? = null, private val showInviteActions: Boolean, val showContactBookAction: Boolean ) : MvRxState { diff --git a/vector/src/main/res/layout/bottom_sheet_space_invite.xml b/vector/src/main/res/layout/bottom_sheet_space_invite.xml index 03893a45f9..1fa132a086 100644 --- a/vector/src/main/res/layout/bottom_sheet_space_invite.xml +++ b/vector/src/main/res/layout/bottom_sheet_space_invite.xml @@ -34,14 +34,14 @@ app:layout_constraintVertical_bias="1" tools:text="@string/invite_people_to_your_space_desc" /> - <im.vector.app.features.spaces.create.WizardButtonView - android:id="@+id/inviteByMailButton" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginBottom="16dp" - app:icon="@drawable/ic_mail" - app:iconTint="?vctr_content_secondary" - app:title="@string/invite_by_email" /> +<!-- <im.vector.app.features.spaces.create.WizardButtonView--> +<!-- android:id="@+id/inviteByMailButton"--> +<!-- android:layout_width="match_parent"--> +<!-- android:layout_height="wrap_content"--> +<!-- android:layout_marginBottom="16dp"--> +<!-- app:icon="@drawable/ic_mail"--> +<!-- app:iconTint="?vctr_content_secondary"--> +<!-- app:title="@string/invite_by_email" />--> <im.vector.app.features.spaces.create.WizardButtonView android:id="@+id/inviteByMxidButton" @@ -50,7 +50,7 @@ android:layout_marginBottom="16dp" app:icon="@drawable/ic_add_people" app:iconTint="?vctr_content_secondary" - app:title="@string/invite_by_mxid" /> + app:title="@string/invite_by_mxid_or_mail" /> <im.vector.app.features.spaces.create.WizardButtonView android:id="@+id/inviteByLinkButton" diff --git a/vector/src/main/res/layout/item_invite_by_mail.xml b/vector/src/main/res/layout/item_invite_by_mail.xml new file mode 100644 index 0000000000..82336d2003 --- /dev/null +++ b/vector/src/main/res/layout/item_invite_by_mail.xml @@ -0,0 +1,80 @@ +<?xml version="1.0" encoding="utf-8"?> + + +<androidx.constraintlayout.widget.ConstraintLayout 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" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:background="?android:colorBackground" + android:foreground="?attr/selectableItemBackground" + android:gravity="center_vertical" + android:orientation="horizontal" + android:padding="8dp"> + + <FrameLayout + android:id="@+id/knownUserAvatarContainer" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginStart="8dp" + android:background="@drawable/rounded_rect_shape_8" + android:backgroundTint="?colorPrimary" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent"> + + <ImageView + android:id="@+id/itemAvatar" + android:layout_width="40dp" + android:layout_height="40dp" + android:importantForAccessibility="no" + android:padding="4dp" + android:src="@drawable/ic_mail" + android:visibility="gone" + app:tint="@android:color/white" + tools:visibility="visible" /> + + <ImageView + android:id="@+id/itemAvatarChecked" + android:layout_width="40dp" + android:layout_height="40dp" + android:contentDescription="@string/a11y_checked" + android:scaleType="centerInside" + android:src="@drawable/ic_material_done" + app:tint="@android:color/white" + tools:ignore="MissingPrefix" /> + </FrameLayout> + + <TextView + android:id="@+id/itemTitle" + style="@style/Widget.Vector.TextView.Subtitle" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_gravity="center_vertical" + android:layout_marginStart="12dp" + android:ellipsize="end" + android:maxLines="1" + android:textColor="?vctr_content_primary" + android:textStyle="bold" + app:layout_constraintBottom_toTopOf="@+id/itemDescription" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintHorizontal_bias="0.5" + app:layout_constraintStart_toEndOf="@+id/knownUserAvatarContainer" + app:layout_constraintTop_toTopOf="parent" + tools:text="foo@example.com" /> + + <TextView + android:id="@+id/itemDescription" + style="@style/Widget.Vector.TextView.Body" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:ellipsize="end" + android:maxLines="1" + android:text="@string/invite_by_email" + android:textColor="?vctr_content_secondary" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintHorizontal_bias="0.5" + app:layout_constraintStart_toStartOf="@+id/itemTitle" + app:layout_constraintTop_toBottomOf="@+id/itemTitle" /> + +</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/vector/src/main/res/values/strings.xml b/vector/src/main/res/values/strings.xml index 453b5ba432..cc8ada06b6 100644 --- a/vector/src/main/res/values/strings.xml +++ b/vector/src/main/res/values/strings.xml @@ -2295,7 +2295,9 @@ <string name="room_filtering_footer_open_room_directory">View the room directory</string> <string name="room_directory_search_hint">Name or ID (#example:matrix.org)</string> + <!-- TO BE REMOVED --> <string name="user_directory_search_hint">Search by name or ID</string> + <string name="user_directory_search_hint_2">Search by name, ID or mail</string> <string name="search_hint_room_name">Search Name</string> @@ -3460,6 +3462,7 @@ <string name="invite_people_to_your_space_desc">It’s just you at the moment. %s will be even better with others.</string> <string name="invite_by_email">Invite by email</string> <string name="invite_by_mxid">Invite by username</string> + <string name="invite_by_mxid_or_mail">Invite by username or mail</string> <string name="invite_by_link">Share link</string> <string name="invite_to_space_with_name">Invite to %s</string> <string name="invite_to_space_with_name_desc">"They’ll be able to explore %s"</string> @@ -3476,6 +3479,13 @@ <string name="create_space_identity_server_info_none">You are not currently using an identity server. In order to invite teammates and be discoverable by them, configure one below.</string> + + <string name="finish_setting_up_discovery">Finish setting up discovery.</string> + <string name="discovery_invite">Invite by email, find contacts and more…</string> + <string name="finish_setup">Finish setup</string> + <string name="discovery_section">Discovery (%s)</string> + + <string name="suggested_rooms_pills_on_empty_text">You’re not in any rooms yet. Below are some suggested rooms, but you can see more with the green button bottom right.</string> <!-- First one is the space name, and the second one is user name --> <string name="suggested_rooms_pills_on_empty_header">Welcome to %1$s, %2$s.</string>