Merge pull request #4051 from vector-im/feature/bca/invite_user_by_mail

Support entering mail in user invite screen
This commit is contained in:
Benoit Marty 2021-09-24 20:34:52 +02:00 committed by GitHub
commit 9a30da13b5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 441 additions and 55 deletions

1
changelog.d/4042.bugfix Normal file
View file

@ -0,0 +1 @@
Private space invite bottomsheet only offering inviting by username not by email

View file

@ -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.crypto.crosssigning.USER_SIGNING_KEY_SSSS_NAME
import org.matrix.android.sdk.api.session.group.GroupSummaryQueryParams 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.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.identity.ThreePid
import org.matrix.android.sdk.api.session.pushers.Pusher import org.matrix.android.sdk.api.session.pushers.Pusher
import org.matrix.android.sdk.api.session.room.RoomSummaryQueryParams import org.matrix.android.sdk.api.session.room.RoomSummaryQueryParams
@ -239,6 +240,10 @@ class RxSession(private val session: Session) {
) )
.distinctUntilChanged() .distinctUntilChanged()
} }
fun lookupThreePid(threePid: ThreePid): Single<Optional<FoundThreePid>> = rxSingle {
session.identityService().lookUp(listOf(threePid)).firstOrNull().toOptional()
}
} }
fun Session.rx(): RxSession { fun Session.rx(): RxSession {

View file

@ -20,10 +20,16 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry import androidx.lifecycle.LifecycleRegistry
import dagger.Lazy 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.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.extensions.tryOrNull
import org.matrix.android.sdk.api.failure.Failure import org.matrix.android.sdk.api.failure.Failure
import org.matrix.android.sdk.api.failure.MatrixError 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.events.model.toModel
import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilitiesService import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilitiesService
import org.matrix.android.sdk.api.session.identity.FoundThreePid 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.di.UnauthenticatedWithCertificate
import org.matrix.android.sdk.internal.extensions.observeNotNull import org.matrix.android.sdk.internal.extensions.observeNotNull
import org.matrix.android.sdk.internal.network.RetrofitFactory 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.SessionScope
import org.matrix.android.sdk.internal.session.identity.data.IdentityStore 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.openid.GetOpenIdTokenTask
import org.matrix.android.sdk.internal.session.profile.BindThreePidsTask 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.profile.UnbindThreePidsTask
import org.matrix.android.sdk.internal.session.sync.model.accountdata.IdentityServerContent 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.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.MatrixCoroutineDispatchers
import org.matrix.android.sdk.internal.util.ensureProtocol 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 timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import javax.net.ssl.HttpsURLConnection import javax.net.ssl.HttpsURLConnection
@ -202,6 +202,8 @@ internal class DefaultIdentityService @Inject constructor(
identityStore.setUrl(urlCandidate) identityStore.setUrl(urlCandidate)
identityStore.setToken(token) identityStore.setToken(token)
// could we remember if it was previously given?
identityStore.setUserConsent(false)
updateIdentityAPI(urlCandidate) updateIdentityAPI(urlCandidate)
updateAccountData(urlCandidate) updateAccountData(urlCandidate)
@ -230,6 +232,8 @@ internal class DefaultIdentityService @Inject constructor(
} }
override suspend fun lookUp(threePids: List<ThreePid>): List<FoundThreePid> { override suspend fun lookUp(threePids: List<ThreePid>): List<FoundThreePid> {
if (getCurrentIdentityServerUrl() == null) throw IdentityServiceError.NoIdentityServerConfigured
if (!getUserConsent()) { if (!getUserConsent()) {
throw IdentityServiceError.UserConsentNotProvided throw IdentityServiceError.UserConsentNotProvided
} }

View file

@ -20,6 +20,7 @@ import android.content.Context
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebViewClient import android.webkit.WebViewClient
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import im.vector.app.R
/** /**
* Open a web view above the current activity. * Open a web view above the current activity.
@ -38,3 +39,14 @@ fun Context.displayInWebView(url: String) {
.setPositiveButton(android.R.string.ok, null) .setPositiveButton(android.R.string.ok, null)
.show() .show()
} }
fun Context.showIdentityServerConsentDialog(configuredIdentityServer: String?, consentCallBack: (() -> Unit)) {
MaterialAlertDialogBuilder(this)
.setTitle(R.string.identity_server_consent_dialog_title)
.setMessage(getString(R.string.identity_server_consent_dialog_content, configuredIdentityServer ?: ""))
.setPositiveButton(R.string.yes) { _, _ ->
consentCallBack.invoke()
}
.setNegativeButton(R.string.no, null)
.show()
}

View file

@ -23,14 +23,13 @@ import android.view.ViewGroup
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.airbnb.mvrx.activityViewModel import com.airbnb.mvrx.activityViewModel
import com.airbnb.mvrx.withState import com.airbnb.mvrx.withState
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.jakewharton.rxbinding3.widget.checkedChanges import com.jakewharton.rxbinding3.widget.checkedChanges
import com.jakewharton.rxbinding3.widget.textChanges import com.jakewharton.rxbinding3.widget.textChanges
import im.vector.app.R
import im.vector.app.core.extensions.cleanup import im.vector.app.core.extensions.cleanup
import im.vector.app.core.extensions.configureWith import im.vector.app.core.extensions.configureWith
import im.vector.app.core.extensions.hideKeyboard import im.vector.app.core.extensions.hideKeyboard
import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.core.utils.showIdentityServerConsentDialog
import im.vector.app.databinding.FragmentContactsBookBinding import im.vector.app.databinding.FragmentContactsBookBinding
import im.vector.app.features.userdirectory.PendingSelection import im.vector.app.features.userdirectory.PendingSelection
import im.vector.app.features.userdirectory.UserListAction import im.vector.app.features.userdirectory.UserListAction
@ -76,14 +75,9 @@ class ContactsBookFragment @Inject constructor(
private fun setupConsentView() { private fun setupConsentView() {
views.phoneBookSearchForMatrixContacts.setOnClickListener { views.phoneBookSearchForMatrixContacts.setOnClickListener {
withState(contactsBookViewModel) { state -> withState(contactsBookViewModel) { state ->
MaterialAlertDialogBuilder(requireActivity()) requireContext().showIdentityServerConsentDialog(state.identityServerUrl) {
.setTitle(R.string.identity_server_consent_dialog_title) contactsBookViewModel.handle(ContactsBookAction.UserConsentGranted)
.setMessage(getString(R.string.identity_server_consent_dialog_content, state.identityServerUrl ?: "")) }
.setPositiveButton(R.string.yes) { _, _ ->
contactsBookViewModel.handle(ContactsBookAction.UserConsentGranted)
}
.setNegativeButton(R.string.no, null)
.show()
} }
} }
} }

View file

@ -32,6 +32,7 @@ import im.vector.app.core.extensions.observeEvent
import im.vector.app.core.extensions.registerStartForActivityResult import im.vector.app.core.extensions.registerStartForActivityResult
import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.core.utils.ensureProtocol import im.vector.app.core.utils.ensureProtocol
import im.vector.app.core.utils.showIdentityServerConsentDialog
import im.vector.app.databinding.FragmentGenericRecyclerBinding import im.vector.app.databinding.FragmentGenericRecyclerBinding
import im.vector.app.features.discovery.change.SetIdentityServerFragment import im.vector.app.features.discovery.change.SetIdentityServerFragment
import im.vector.app.features.settings.VectorSettingsActivity import im.vector.app.features.settings.VectorSettingsActivity
@ -179,14 +180,9 @@ class DiscoverySettingsFragment @Inject constructor(
override fun onTapUpdateUserConsent(newValue: Boolean) { override fun onTapUpdateUserConsent(newValue: Boolean) {
if (newValue) { if (newValue) {
withState(viewModel) { state -> withState(viewModel) { state ->
MaterialAlertDialogBuilder(requireActivity()) requireContext().showIdentityServerConsentDialog(state.identityServer.invoke()) {
.setTitle(R.string.identity_server_consent_dialog_title) viewModel.handle(DiscoverySettingsAction.UpdateUserConsent(true))
.setMessage(getString(R.string.identity_server_consent_dialog_content, state.identityServer.invoke())) }
.setPositiveButton(R.string.yes) { _, _ ->
viewModel.handle(DiscoverySettingsAction.UpdateUserConsent(true))
}
.setNegativeButton(R.string.no, null)
.show()
} }
} else { } else {
viewModel.handle(DiscoverySettingsAction.UpdateUserConsent(false)) viewModel.handle(DiscoverySettingsAction.UpdateUserConsent(false))

View file

@ -65,7 +65,7 @@ class DiscoverySettingsViewModel @AssistedInject constructor(
setState { setState {
copy( copy(
identityServer = Success(identityServerUrl), identityServer = Success(identityServerUrl),
userConsent = false userConsent = identityService.getUserConsent()
) )
} }
if (currentIS != identityServerUrl) retrieveBinding() if (currentIS != identityServerUrl) retrieveBinding()

View file

@ -73,7 +73,6 @@ class ShareSpaceBottomSheet : VectorBaseBottomSheetDialogFragment<BottomSheetSpa
views.descriptionText.setTextOrHide(null) views.descriptionText.setTextOrHide(null)
} }
views.inviteByMailButton.isVisible = false // not yet implemented
views.inviteByLinkButton.isVisible = state.canShareLink views.inviteByLinkButton.isVisible = state.canShareLink
views.inviteByMxidButton.isVisible = state.canInviteByMxId views.inviteByMxidButton.isVisible = state.canInviteByMxId
} }
@ -81,11 +80,6 @@ class ShareSpaceBottomSheet : VectorBaseBottomSheetDialogFragment<BottomSheetSpa
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
// XXX enable back when supported
views.inviteByMailButton.isVisible = false
views.inviteByMailButton.debouncedClicks {
}
views.inviteByMxidButton.debouncedClicks { views.inviteByMxidButton.debouncedClicks {
viewModel.handle(ShareSpaceAction.InviteByMxId) viewModel.handle(ShareSpaceAction.InviteByMxId)
} }

View file

@ -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 InviteByEmailItem : VectorEpoxyModel<InviteByEmailItem.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)
}
}

View file

@ -24,4 +24,5 @@ sealed class UserListAction : VectorViewModelAction {
data class AddPendingSelection(val pendingSelection: PendingSelection) : UserListAction() data class AddPendingSelection(val pendingSelection: PendingSelection) : UserListAction()
data class RemovePendingSelection(val pendingSelection: PendingSelection) : UserListAction() data class RemovePendingSelection(val pendingSelection: PendingSelection) : UserListAction()
object ComputeMatrixToLinkForSharing : UserListAction() object ComputeMatrixToLinkForSharing : UserListAction()
data class UpdateUserConsent(val consent: Boolean) : UserListAction()
} }

View file

@ -26,9 +26,13 @@ import im.vector.app.core.epoxy.errorWithRetryItem
import im.vector.app.core.epoxy.loadingItem import im.vector.app.core.epoxy.loadingItem
import im.vector.app.core.epoxy.noResultItem import im.vector.app.core.epoxy.noResultItem
import im.vector.app.core.error.ErrorFormatter 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.resources.StringProvider
import im.vector.app.core.ui.list.genericPillItem
import im.vector.app.features.home.AvatarRenderer 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.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.identity.ThreePid
import org.matrix.android.sdk.api.session.user.model.User 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.toMatrixItem
@ -37,6 +41,7 @@ import javax.inject.Inject
class UserListController @Inject constructor(private val session: Session, class UserListController @Inject constructor(private val session: Session,
private val avatarRenderer: AvatarRenderer, private val avatarRenderer: AvatarRenderer,
private val stringProvider: StringProvider, private val stringProvider: StringProvider,
private val colorProvider: ColorProvider,
private val errorFormatter: ErrorFormatter) : EpoxyController() { private val errorFormatter: ErrorFormatter) : EpoxyController() {
private var state: UserListViewState? = null private var state: UserListViewState? = null
@ -86,6 +91,119 @@ class UserListController @Inject constructor(private val session: Session,
} }
} }
when (val matchingEmail = currentState.matchingEmail) {
is Success -> {
matchingEmail()?.let { threePidUser ->
userListHeaderItem {
id("identity_server_result_header")
header(host.stringProvider.getString(R.string.discovery_section, currentState.configuredIdentityServer ?: ""))
}
val isSelected = currentState.pendingSelections.any { pendingSelection ->
when (pendingSelection) {
is PendingSelection.ThreePidPendingSelection -> {
when (pendingSelection.threePid) {
is ThreePid.Email -> pendingSelection.threePid.email == threePidUser.email
is ThreePid.Msisdn -> false
}
}
is PendingSelection.UserPendingSelection -> {
threePidUser.user != null && threePidUser.user.userId == pendingSelection.user.userId
}
}
}
if (threePidUser.user == null) {
inviteByEmailItem {
id("email_${threePidUser.email}")
foundItem(threePidUser)
selected(isSelected)
clickListener {
host.callback?.onThreePidClick(ThreePid.Email(threePidUser.email))
}
}
} else {
userDirectoryUserItem {
id(threePidUser.user.userId)
selected(isSelected)
matrixItem(threePidUser.user.toMatrixItem().let {
it.copy(
displayName = "${it.getBestName()} [${threePidUser.email}]"
)
})
avatarRenderer(host.avatarRenderer)
clickListener {
host.callback?.onItemClick(threePidUser.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("identity_server_result_header_loading")
header(host.stringProvider.getString(R.string.discovery_section, currentState.configuredIdentityServer ?: ""))
}
loadingItem {
id("is_loading")
}
}
else -> {
// nop
}
}
when (currentState.knownUsers) { when (currentState.knownUsers) {
is Uninitialized -> renderEmptyState() is Uninitialized -> renderEmptyState()
is Loading -> renderLoading() is Loading -> renderLoading()
@ -196,5 +314,7 @@ class UserListController @Inject constructor(private val session: Session,
fun onItemClick(user: User) fun onItemClick(user: User)
fun onMatrixIdClick(matrixId: String) fun onMatrixIdClick(matrixId: String)
fun onThreePidClick(threePid: ThreePid) fun onThreePidClick(threePid: ThreePid)
fun onSetupDiscovery()
fun giveIdentityServerConsent()
} }
} }

View file

@ -39,9 +39,11 @@ import im.vector.app.core.extensions.hideKeyboard
import im.vector.app.core.extensions.setupAsSearch import im.vector.app.core.extensions.setupAsSearch
import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.core.utils.DimensionConverter import im.vector.app.core.utils.DimensionConverter
import im.vector.app.core.utils.showIdentityServerConsentDialog
import im.vector.app.core.utils.startSharePlainTextIntent import im.vector.app.core.utils.startSharePlainTextIntent
import im.vector.app.databinding.FragmentUserListBinding import im.vector.app.databinding.FragmentUserListBinding
import im.vector.app.features.homeserver.HomeServerCapabilitiesViewModel 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.identity.ThreePid
import org.matrix.android.sdk.api.session.user.model.User import org.matrix.android.sdk.api.session.user.model.User
@ -131,7 +133,7 @@ class UserListFragment @Inject constructor(
private fun setupSearchView() { private fun setupSearchView() {
withState(viewModel) { 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 views.userListSearch
.textChanges() .textChanges()
@ -217,6 +219,21 @@ class UserListFragment @Inject constructor(
viewModel.handle(UserListAction.AddPendingSelection(PendingSelection.ThreePidPendingSelection(threePid))) 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 ->
requireContext().showIdentityServerConsentDialog(state.configuredIdentityServer) {
viewModel.handle(UserListAction.UpdateUserConsent(true))
}
}
}
override fun onUseQRCode() { override fun onUseQRCode() {
view?.hideKeyboard() view?.hideKeyboard()
sharedActionViewModel.post(UserListSharedAction.AddByQrCode) sharedActionViewModel.post(UserListSharedAction.AddByQrCode)

View file

@ -19,18 +19,22 @@ package im.vector.app.features.userdirectory
import com.airbnb.mvrx.ActivityViewModelContext import com.airbnb.mvrx.ActivityViewModelContext
import com.airbnb.mvrx.FragmentViewModelContext import com.airbnb.mvrx.FragmentViewModelContext
import com.airbnb.mvrx.MvRxViewModelFactory import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.Uninitialized
import com.airbnb.mvrx.ViewModelContext import com.airbnb.mvrx.ViewModelContext
import com.jakewharton.rxrelay2.BehaviorRelay import com.jakewharton.rxrelay2.BehaviorRelay
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import im.vector.app.core.extensions.exhaustive 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.extensions.toggle
import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.platform.VectorViewModel
import io.reactivex.Single import io.reactivex.Single
import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.android.schedulers.AndroidSchedulers
import org.matrix.android.sdk.api.MatrixPatterns import org.matrix.android.sdk.api.MatrixPatterns
import org.matrix.android.sdk.api.session.Session 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.profile.ProfileService
import org.matrix.android.sdk.api.session.user.model.User 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.toMatrixItem
@ -41,12 +45,18 @@ import java.util.concurrent.TimeUnit
private typealias KnownUsersSearch = String private typealias KnownUsersSearch = String
private typealias DirectoryUsersSearch = String private typealias DirectoryUsersSearch = String
data class ThreePidUser(
val email: String,
val user: User?
)
class UserListViewModel @AssistedInject constructor(@Assisted initialState: UserListViewState, class UserListViewModel @AssistedInject constructor(@Assisted initialState: UserListViewState,
private val session: Session) private val session: Session)
: VectorViewModel<UserListViewState, UserListAction, UserListViewEvents>(initialState) { : VectorViewModel<UserListViewState, UserListAction, UserListViewEvents>(initialState) {
private val knownUsersSearch = BehaviorRelay.create<KnownUsersSearch>() private val knownUsersSearch = BehaviorRelay.create<KnownUsersSearch>()
private val directoryUsersSearch = BehaviorRelay.create<DirectoryUsersSearch>() private val directoryUsersSearch = BehaviorRelay.create<DirectoryUsersSearch>()
private val identityServerUsersSearch = BehaviorRelay.create<String>()
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
@ -64,24 +74,72 @@ 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 { init {
observeUsers() observeUsers()
setState {
copy(
configuredIdentityServer = cleanISURL(session.identityService().getCurrentIdentityServerUrl())
)
}
session.identityService().addListener(identityServerListener)
}
private fun cleanISURL(url: String?): String? {
return url?.removePrefix("https://")
}
override fun onCleared() {
session.identityService().removeListener(identityServerListener)
super.onCleared()
} }
override fun handle(action: UserListAction) { override fun handle(action: UserListAction) {
when (action) { when (action) {
is UserListAction.SearchUsers -> handleSearchUsers(action.value) is UserListAction.SearchUsers -> handleSearchUsers(action.value)
is UserListAction.ClearSearchUsers -> handleClearSearchUsers() is UserListAction.ClearSearchUsers -> handleClearSearchUsers()
is UserListAction.AddPendingSelection -> handleSelectUser(action) is UserListAction.AddPendingSelection -> handleSelectUser(action)
is UserListAction.RemovePendingSelection -> handleRemoveSelectedUser(action) is UserListAction.RemovePendingSelection -> handleRemoveSelectedUser(action)
UserListAction.ComputeMatrixToLinkForSharing -> handleShareMyMatrixToLink() UserListAction.ComputeMatrixToLinkForSharing -> handleShareMyMatrixToLink()
is UserListAction.UpdateUserConsent -> handleISUpdateConsent(action)
}.exhaustive }.exhaustive
} }
private fun handleISUpdateConsent(action: UserListAction.UpdateUserConsent) {
session.identityService().setUserConsent(action.consent)
withState {
identityServerUsersSearch.accept(it.searchTerm)
}
}
private fun handleSearchUsers(searchTerm: String) { private fun handleSearchUsers(searchTerm: String) {
setState { 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) knownUsersSearch.accept(searchTerm)
directoryUsersSearch.accept(searchTerm) directoryUsersSearch.accept(searchTerm)
} }
@ -95,12 +153,45 @@ class UserListViewModel @AssistedInject constructor(@Assisted initialState: User
private fun handleClearSearchUsers() { private fun handleClearSearchUsers() {
knownUsersSearch.accept("") knownUsersSearch.accept("")
directoryUsersSearch.accept("") directoryUsersSearch.accept("")
identityServerUsersSearch.accept("")
setState { setState {
copy(searchTerm = "") copy(searchTerm = "")
} }
} }
private fun observeUsers() = withState { state -> 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
)
)
}
.onErrorResumeNext {
Single.just(ThreePidUser(email = search, user = User(foundThreePid.matrixId)))
}
} ?: Single.just(ThreePidUser(email = search, user = null))
}
stream.toAsync {
copy(matchingEmail = it)
}
}
.subscribe()
.disposeOnClear()
knownUsersSearch knownUsersSearch
.throttleLast(300, TimeUnit.MILLISECONDS) .throttleLast(300, TimeUnit.MILLISECONDS)
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
@ -136,14 +227,16 @@ class UserListViewModel @AssistedInject constructor(@Assisted initialState: User
avatarUrl = json[ProfileService.AVATAR_URL_KEY] as? String avatarUrl = json[ProfileService.AVATAR_URL_KEY] as? String
).toOptional() ).toOptional()
} }
.onErrorReturn { .onErrorResumeNext {
// Profile API can be restricted and doesn't have to return result. // Profile API can be restricted and doesn't have to return result.
// In this case allow inviting valid user ids. // In this case allow inviting valid user ids.
User( Single.just(
userId = search, User(
displayName = null, userId = search,
avatarUrl = null displayName = null,
).toOptional() avatarUrl = null
).toOptional()
)
} }
Single.zip( Single.zip(

View file

@ -27,10 +27,12 @@ data class UserListViewState(
val excludedUserIds: Set<String>? = null, val excludedUserIds: Set<String>? = null,
val knownUsers: Async<PagedList<User>> = Uninitialized, val knownUsers: Async<PagedList<User>> = Uninitialized,
val directoryUsers: Async<List<User>> = Uninitialized, val directoryUsers: Async<List<User>> = Uninitialized,
val matchingEmail: Async<ThreePidUser?> = Uninitialized,
val filteredMappedContacts: List<MappedContact> = emptyList(), val filteredMappedContacts: List<MappedContact> = emptyList(),
val pendingSelections: Set<PendingSelection> = emptySet(), val pendingSelections: Set<PendingSelection> = emptySet(),
val searchTerm: String = "", val searchTerm: String = "",
val singleSelection: Boolean, val singleSelection: Boolean,
val configuredIdentityServer: String? = null,
private val showInviteActions: Boolean, private val showInviteActions: Boolean,
val showContactBookAction: Boolean val showContactBookAction: Boolean
) : MvRxState { ) : MvRxState {

View file

@ -34,14 +34,14 @@
app:layout_constraintVertical_bias="1" app:layout_constraintVertical_bias="1"
tools:text="@string/invite_people_to_your_space_desc" /> tools:text="@string/invite_people_to_your_space_desc" />
<im.vector.app.features.spaces.create.WizardButtonView <!-- <im.vector.app.features.spaces.create.WizardButtonView-->
android:id="@+id/inviteByMailButton" <!-- android:id="@+id/inviteByMailButton"-->
android:layout_width="match_parent" <!-- android:layout_width="match_parent"-->
android:layout_height="wrap_content" <!-- android:layout_height="wrap_content"-->
android:layout_marginBottom="16dp" <!-- android:layout_marginBottom="16dp"-->
app:icon="@drawable/ic_mail" <!-- app:icon="@drawable/ic_mail"-->
app:iconTint="?vctr_content_secondary" <!-- app:iconTint="?vctr_content_secondary"-->
app:title="@string/invite_by_email" /> <!-- app:title="@string/invite_by_email" />-->
<im.vector.app.features.spaces.create.WizardButtonView <im.vector.app.features.spaces.create.WizardButtonView
android:id="@+id/inviteByMxidButton" android:id="@+id/inviteByMxidButton"
@ -50,7 +50,7 @@
android:layout_marginBottom="16dp" android:layout_marginBottom="16dp"
app:icon="@drawable/ic_add_people" app:icon="@drawable/ic_add_people"
app:iconTint="?vctr_content_secondary" 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 <im.vector.app.features.spaces.create.WizardButtonView
android:id="@+id/inviteByLinkButton" android:id="@+id/inviteByLinkButton"

View file

@ -0,0 +1,78 @@
<?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/iconContainer"
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/iconContainer"
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>

View file

@ -2295,7 +2295,9 @@
<string name="room_filtering_footer_open_room_directory">View the room directory</string> <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> <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">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> <string name="search_hint_room_name">Search Name</string>
@ -3460,6 +3462,7 @@
<string name="invite_people_to_your_space_desc">Its just you at the moment. %s will be even better with others.</string> <string name="invite_people_to_your_space_desc">Its 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_email">Invite by email</string>
<string name="invite_by_mxid">Invite by username</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_by_link">Share link</string>
<string name="invite_to_space_with_name">Invite to %s</string> <string name="invite_to_space_with_name">Invite to %s</string>
<string name="invite_to_space_with_name_desc">"Theyll be able to explore %s"</string> <string name="invite_to_space_with_name_desc">"Theyll be able to explore %s"</string>
@ -3476,6 +3479,14 @@
<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="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>
<!-- %s will be replaced by the user identity server domain, e.g vector.im -->
<string name="discovery_section">Discovery (%s)</string>
<string name="suggested_rooms_pills_on_empty_text">Youre not in any rooms yet. Below are some suggested rooms, but you can see more with the green button bottom right.</string> <string name="suggested_rooms_pills_on_empty_text">Youre 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 --> <!-- 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> <string name="suggested_rooms_pills_on_empty_header">Welcome to %1$s, %2$s.</string>