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> = 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): List { + 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() { + + @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(R.id.itemTitle) + val avatarImageView by bind(R.id.itemAvatar) + val checkedImageView by bind(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() private val directoryUsersSearch = BehaviorRelay.create() + private val identityServerUsersSearch = BehaviorRelay.create() @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? = null, val knownUsers: Async> = Uninitialized, val directoryUsers: Async> = Uninitialized, + val matchingEmail: Async = Uninitialized, val filteredMappedContacts: List = emptyList(), val pendingSelections: Set = 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" /> - + + + + + + + + + app:title="@string/invite_by_mxid_or_mail" /> + + + + + + + + + + + + + + + + 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 @@ View the room directory Name or ID (#example:matrix.org) + Search by name or ID + Search by name, ID or mail Search Name @@ -3460,6 +3462,7 @@ It’s just you at the moment. %s will be even better with others. Invite by email Invite by username + Invite by username or mail Share link Invite to %s "They’ll be able to explore %s" @@ -3476,6 +3479,13 @@ You are not currently using an identity server. In order to invite teammates and be discoverable by them, configure one below. + + Finish setting up discovery. + Invite by email, find contacts and more… + Finish setup + Discovery (%s) + + You’re not in any rooms yet. Below are some suggested rooms, but you can see more with the green button bottom right. Welcome to %1$s, %2$s.