diff --git a/CHANGES.md b/CHANGES.md index 4873a999b6..e1731433c3 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -3,6 +3,8 @@ Changes in Element 1.0.11 (2020-XX-XX) Features ✨: - Create DMs with users by scanning their QR code (#2025) + - Add Invite friends quick invite actions (#2348) + - Add friend by scanning QR code, show your code to friends (#2025) Improvements 🙌: - New room creation tile with quick action (#2346) @@ -12,6 +14,7 @@ Improvements 🙌: - Handle events of type "m.room.server_acl" (#890) - Room creation form: add advanced section to disable federation (#1314) - Move "Enable Encryption" from room setting screen to room profile screen (#2394) + - Improve Invite user screen (seamless search for matrix ID) Bugfix 🐛: - Fix crash on AttachmentViewer (#2365) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/login/LoginWizard.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/login/LoginWizard.kt index 645fb55bb9..48705ee7b7 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/login/LoginWizard.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/login/LoginWizard.kt @@ -27,7 +27,7 @@ interface LoginWizard { * @param password the password field * @param deviceName the initial device name * @param callback the matrix callback on which you'll receive the result of authentication. - * @return return a [Cancelable] + * @return a [Cancelable] */ fun login(login: String, password: String, diff --git a/vector/src/main/AndroidManifest.xml b/vector/src/main/AndroidManifest.xml index fb4764b3be..7d2ca11813 100644 --- a/vector/src/main/AndroidManifest.xml +++ b/vector/src/main/AndroidManifest.xml @@ -229,6 +229,7 @@ + diff --git a/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt b/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt index acdad5407c..32c98922fb 100644 --- a/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt +++ b/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt @@ -111,8 +111,8 @@ import im.vector.app.features.settings.threepids.ThreePidsSettingsFragment import im.vector.app.features.share.IncomingShareFragment import im.vector.app.features.signout.soft.SoftLogoutFragment import im.vector.app.features.terms.ReviewTermsFragment -import im.vector.app.features.userdirectory.KnownUsersFragment -import im.vector.app.features.userdirectory.UserDirectoryFragment +import im.vector.app.features.usercode.ShowUserCodeFragment +import im.vector.app.features.userdirectory.UserListFragment import im.vector.app.features.widgets.WidgetFragment @Module @@ -255,13 +255,8 @@ interface FragmentModule { @Binds @IntoMap - @FragmentKey(UserDirectoryFragment::class) - fun bindUserDirectoryFragment(fragment: UserDirectoryFragment): Fragment - - @Binds - @IntoMap - @FragmentKey(KnownUsersFragment::class) - fun bindKnownUsersFragment(fragment: KnownUsersFragment): Fragment + @FragmentKey(UserListFragment::class) + fun bindUserListFragment(fragment: UserListFragment): Fragment @Binds @IntoMap @@ -582,4 +577,9 @@ interface FragmentModule { @IntoMap @FragmentKey(SearchFragment::class) fun bindSearchFragment(fragment: SearchFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(ShowUserCodeFragment::class) + fun bindShowUserCodeFragment(fragment: ShowUserCodeFragment): Fragment } diff --git a/vector/src/main/java/im/vector/app/core/di/ScreenComponent.kt b/vector/src/main/java/im/vector/app/core/di/ScreenComponent.kt index fde40f9195..818a32fca3 100644 --- a/vector/src/main/java/im/vector/app/core/di/ScreenComponent.kt +++ b/vector/src/main/java/im/vector/app/core/di/ScreenComponent.kt @@ -50,6 +50,7 @@ import im.vector.app.features.invite.InviteUsersToRoomActivity import im.vector.app.features.invite.VectorInviteView import im.vector.app.features.link.LinkHandlerActivity import im.vector.app.features.login.LoginActivity +import im.vector.app.features.matrixto.MatrixToBottomSheet import im.vector.app.features.media.BigImageViewerActivity import im.vector.app.features.media.VectorAttachmentViewerActivity import im.vector.app.features.navigation.Navigator @@ -72,6 +73,7 @@ import im.vector.app.features.share.IncomingShareActivity import im.vector.app.features.signout.soft.SoftLogoutActivity import im.vector.app.features.terms.ReviewTermsActivity import im.vector.app.features.ui.UiStateRepository +import im.vector.app.features.usercode.UserCodeActivity import im.vector.app.features.widgets.WidgetActivity import im.vector.app.features.widgets.permissions.RoomWidgetPermissionBottomSheet import im.vector.app.features.workers.signout.SignOutBottomSheetDialogFragment @@ -140,6 +142,7 @@ interface ScreenComponent { fun inject(activity: VectorAttachmentViewerActivity) fun inject(activity: VectorJitsiActivity) fun inject(activity: SearchActivity) + fun inject(activity: UserCodeActivity) /* ========================================================================================== * BottomSheets @@ -158,6 +161,7 @@ interface ScreenComponent { fun inject(bottomSheet: RoomWidgetsBottomSheet) fun inject(bottomSheet: CallControlsBottomSheet) fun inject(bottomSheet: SignOutBottomSheetDialogFragment) + fun inject(bottomSheet: MatrixToBottomSheet) /* ========================================================================================== * Others diff --git a/vector/src/main/java/im/vector/app/core/di/ViewModelModule.kt b/vector/src/main/java/im/vector/app/core/di/ViewModelModule.kt index 836dab00c5..7ae8bc9c2e 100644 --- a/vector/src/main/java/im/vector/app/core/di/ViewModelModule.kt +++ b/vector/src/main/java/im/vector/app/core/di/ViewModelModule.kt @@ -35,7 +35,7 @@ import im.vector.app.features.home.room.list.actions.RoomListQuickActionsSharedA import im.vector.app.features.reactions.EmojiChooserViewModel import im.vector.app.features.roomdirectory.RoomDirectorySharedActionViewModel import im.vector.app.features.roomprofile.RoomProfileSharedActionViewModel -import im.vector.app.features.userdirectory.UserDirectorySharedActionViewModel +import im.vector.app.features.userdirectory.UserListSharedActionViewModel @Module interface ViewModelModule { @@ -87,8 +87,8 @@ interface ViewModelModule { @Binds @IntoMap - @ViewModelKey(UserDirectorySharedActionViewModel::class) - fun bindUserDirectorySharedActionViewModel(viewModel: UserDirectorySharedActionViewModel): ViewModel + @ViewModelKey(UserListSharedActionViewModel::class) + fun bindUserListSharedActionViewModel(viewModel: UserListSharedActionViewModel): ViewModel @Binds @IntoMap diff --git a/vector/src/main/java/im/vector/app/core/epoxy/CheckBoxItem.kt b/vector/src/main/java/im/vector/app/core/epoxy/CheckBoxItem.kt new file mode 100644 index 0000000000..4e53b293d3 --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/epoxy/CheckBoxItem.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2020 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.core.epoxy + +import android.widget.CompoundButton +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import com.google.android.material.checkbox.MaterialCheckBox +import im.vector.app.R +import kotlinx.android.synthetic.main.vector_preference_push_rule.view.* + +@EpoxyModelClass(layout = R.layout.item_checkbox) +abstract class CheckBoxItem : VectorEpoxyModel() { + + @EpoxyAttribute + var checked: Boolean = false + + @EpoxyAttribute lateinit var title: String + + @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) + var checkChangeListener: CompoundButton.OnCheckedChangeListener? = null + + override fun bind(holder: Holder) { + super.bind(holder) + holder.checkbox.isChecked = checked + holder.checkbox.text = title + holder.checkbox.setOnCheckedChangeListener(checkChangeListener) + } + + class Holder : VectorEpoxyHolder() { + val checkbox by bind(R.id.checkbox) + } +} diff --git a/vector/src/main/java/im/vector/app/core/extensions/EditText.kt b/vector/src/main/java/im/vector/app/core/extensions/EditText.kt index 355dd8442f..05b70def3d 100644 --- a/vector/src/main/java/im/vector/app/core/extensions/EditText.kt +++ b/vector/src/main/java/im/vector/app/core/extensions/EditText.kt @@ -26,7 +26,7 @@ import androidx.annotation.DrawableRes import im.vector.app.R import im.vector.app.core.platform.SimpleTextWatcher -fun EditText.setupAsSearch(@DrawableRes searchIconRes: Int = R.drawable.ic_filter, +fun EditText.setupAsSearch(@DrawableRes searchIconRes: Int = R.drawable.ic_search, @DrawableRes clearIconRes: Int = R.drawable.ic_x_gray) { addTextChangedListener(object : SimpleTextWatcher() { override fun afterTextChanged(s: Editable) { diff --git a/vector/src/main/java/im/vector/app/core/utils/SystemUtils.kt b/vector/src/main/java/im/vector/app/core/utils/SystemUtils.kt index d228adab12..2348b07c7b 100644 --- a/vector/src/main/java/im/vector/app/core/utils/SystemUtils.kt +++ b/vector/src/main/java/im/vector/app/core/utils/SystemUtils.kt @@ -136,13 +136,19 @@ fun startSharePlainTextIntent(fragment: Fragment, activityResultLauncher: ActivityResultLauncher?, chooserTitle: String?, text: String, - subject: String? = null) { + subject: String? = null, + extraTitle: String? = null) { val share = Intent(Intent.ACTION_SEND) share.type = "text/plain" share.addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT) // Add data to the intent, the receiving app will decide what to do with it. share.putExtra(Intent.EXTRA_SUBJECT, subject) share.putExtra(Intent.EXTRA_TEXT, text) + + extraTitle?.let { + share.putExtra(Intent.EXTRA_TITLE, it) + } + val intent = Intent.createChooser(share, chooserTitle) try { if (activityResultLauncher != null) { diff --git a/vector/src/main/java/im/vector/app/features/contactsbook/ContactsBookFragment.kt b/vector/src/main/java/im/vector/app/features/contactsbook/ContactsBookFragment.kt index 23d21f5240..6c3ec06f75 100644 --- a/vector/src/main/java/im/vector/app/features/contactsbook/ContactsBookFragment.kt +++ b/vector/src/main/java/im/vector/app/features/contactsbook/ContactsBookFragment.kt @@ -30,10 +30,10 @@ import im.vector.app.core.extensions.configureWith import im.vector.app.core.extensions.hideKeyboard import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.features.userdirectory.PendingInvitee -import im.vector.app.features.userdirectory.UserDirectoryAction -import im.vector.app.features.userdirectory.UserDirectorySharedAction -import im.vector.app.features.userdirectory.UserDirectorySharedActionViewModel -import im.vector.app.features.userdirectory.UserDirectoryViewModel +import im.vector.app.features.userdirectory.UserListAction +import im.vector.app.features.userdirectory.UserListSharedAction +import im.vector.app.features.userdirectory.UserListSharedActionViewModel +import im.vector.app.features.userdirectory.UserListViewModel import kotlinx.android.synthetic.main.fragment_contacts_book.* import org.matrix.android.sdk.api.session.identity.ThreePid import org.matrix.android.sdk.api.session.user.model.User @@ -46,16 +46,16 @@ class ContactsBookFragment @Inject constructor( ) : VectorBaseFragment(), ContactsBookController.Callback { override fun getLayoutResId() = R.layout.fragment_contacts_book - private val viewModel: UserDirectoryViewModel by activityViewModel() + private val viewModel: UserListViewModel by activityViewModel() // Use activityViewModel to avoid loading several times the data private val contactsBookViewModel: ContactsBookViewModel by activityViewModel() - private lateinit var sharedActionViewModel: UserDirectorySharedActionViewModel + private lateinit var sharedActionViewModel: UserListSharedActionViewModel override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - sharedActionViewModel = activityViewModelProvider.get(UserDirectorySharedActionViewModel::class.java) + sharedActionViewModel = activityViewModelProvider.get(UserListSharedActionViewModel::class.java) setupRecyclerView() setupFilterView() setupConsentView() @@ -110,7 +110,7 @@ class ContactsBookFragment @Inject constructor( private fun setupCloseView() { phoneBookClose.debouncedClicks { - sharedActionViewModel.post(UserDirectorySharedAction.GoBack) + sharedActionViewModel.post(UserListSharedAction.GoBack) } } @@ -122,13 +122,13 @@ class ContactsBookFragment @Inject constructor( override fun onMatrixIdClick(matrixId: String) { view?.hideKeyboard() - viewModel.handle(UserDirectoryAction.SelectPendingInvitee(PendingInvitee.UserPendingInvitee(User(matrixId)))) - sharedActionViewModel.post(UserDirectorySharedAction.GoBack) + viewModel.handle(UserListAction.SelectPendingInvitee(PendingInvitee.UserPendingInvitee(User(matrixId)))) + sharedActionViewModel.post(UserListSharedAction.GoBack) } override fun onThreePidClick(threePid: ThreePid) { view?.hideKeyboard() - viewModel.handle(UserDirectoryAction.SelectPendingInvitee(PendingInvitee.ThreePidPendingInvitee(threePid))) - sharedActionViewModel.post(UserDirectorySharedAction.GoBack) + viewModel.handle(UserListAction.SelectPendingInvitee(PendingInvitee.ThreePidPendingInvitee(threePid))) + sharedActionViewModel.post(UserListSharedAction.GoBack) } } diff --git a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomActivity.kt b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomActivity.kt index 2035ee50f6..2e21d04d06 100644 --- a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomActivity.kt +++ b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomActivity.kt @@ -45,23 +45,23 @@ import im.vector.app.core.utils.allGranted import im.vector.app.core.utils.checkPermissions import im.vector.app.features.contactsbook.ContactsBookFragment import im.vector.app.features.contactsbook.ContactsBookViewModel -import im.vector.app.features.userdirectory.KnownUsersFragment -import im.vector.app.features.userdirectory.KnownUsersFragmentArgs -import im.vector.app.features.userdirectory.UserDirectoryFragment -import im.vector.app.features.userdirectory.UserDirectorySharedAction -import im.vector.app.features.userdirectory.UserDirectorySharedActionViewModel -import im.vector.app.features.userdirectory.UserDirectoryViewModel +import im.vector.app.features.userdirectory.UserListFragment +import im.vector.app.features.userdirectory.UserListFragmentArgs +import im.vector.app.features.userdirectory.UserListSharedAction +import im.vector.app.features.userdirectory.UserListSharedActionViewModel +import im.vector.app.features.userdirectory.UserListViewModel +import im.vector.app.features.userdirectory.UserListViewState import kotlinx.android.synthetic.main.activity.* import org.matrix.android.sdk.api.failure.Failure import org.matrix.android.sdk.api.session.room.failure.CreateRoomFailure import java.net.HttpURLConnection import javax.inject.Inject -class CreateDirectRoomActivity : SimpleFragmentActivity() { +class CreateDirectRoomActivity : SimpleFragmentActivity(), UserListViewModel.Factory { private val viewModel: CreateDirectRoomViewModel by viewModel() - private lateinit var sharedActionViewModel: UserDirectorySharedActionViewModel - @Inject lateinit var userDirectoryViewModelFactory: UserDirectoryViewModel.Factory + private lateinit var sharedActionViewModel: UserListSharedActionViewModel + @Inject lateinit var userListViewModelFactory: UserListViewModel.Factory @Inject lateinit var createDirectRoomViewModelFactory: CreateDirectRoomViewModel.Factory @Inject lateinit var contactsBookViewModelFactory: ContactsBookViewModel.Factory @Inject lateinit var errorFormatter: ErrorFormatter @@ -71,37 +71,36 @@ class CreateDirectRoomActivity : SimpleFragmentActivity() { injector.inject(this) } + override fun create(initialState: UserListViewState, args: UserListFragmentArgs): UserListViewModel { + return userListViewModelFactory.create(initialState, args) + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) toolbar.visibility = View.GONE - sharedActionViewModel = viewModelProvider.get(UserDirectorySharedActionViewModel::class.java) - if (intent?.getBooleanExtra(BY_QR_CODE, false)!!) { - if (isFirstCreation()) { openAddByQrCode() } - } else { - sharedActionViewModel - .observe() - .subscribe { sharedAction -> - when (sharedAction) { - UserDirectorySharedAction.OpenUsersDirectory -> - addFragmentToBackstack(R.id.container, UserDirectoryFragment::class.java) - UserDirectorySharedAction.Close -> finish() - UserDirectorySharedAction.GoBack -> onBackPressed() - is UserDirectorySharedAction.OnMenuItemSelected -> onMenuItemSelected(sharedAction) - UserDirectorySharedAction.OpenPhoneBook -> openPhoneBook() - }.exhaustive - } - .disposeOnDestroy() - if (isFirstCreation()) { - addFragment( - R.id.container, - KnownUsersFragment::class.java, - KnownUsersFragmentArgs( - title = getString(R.string.fab_menu_create_chat), - menuResId = R.menu.vector_create_direct_room, - isCreatingRoom = true - ) - ) - } + + sharedActionViewModel = viewModelProvider.get(UserListSharedActionViewModel::class.java) + sharedActionViewModel + .observe() + .subscribe { action -> + when (action) { + UserListSharedAction.Close -> finish() + UserListSharedAction.GoBack -> onBackPressed() + is UserListSharedAction.OnMenuItemSelected -> onMenuItemSelected(action) + UserListSharedAction.OpenPhoneBook -> openPhoneBook() + UserListSharedAction.AddByQrCode -> openAddByQrCode() + }.exhaustive + } + .disposeOnDestroy() + if (isFirstCreation()) { + addFragment( + R.id.container, + UserListFragment::class.java, + UserListFragmentArgs( + title = getString(R.string.fab_menu_create_chat), + menuResId = R.menu.vector_create_direct_room + ) + ) } viewModel.selectSubscribe(this, CreateDirectRoomViewState::createAndInviteState) { renderCreateAndInviteState(it) @@ -129,22 +128,22 @@ class CreateDirectRoomActivity : SimpleFragmentActivity() { if (allGranted(grantResults)) { if (requestCode == PERMISSION_REQUEST_CODE_READ_CONTACTS) { doOnPostResume { addFragmentToBackstack(R.id.container, ContactsBookFragment::class.java) } - } else if (requestCode == PERMISSION_REQUEST_CODE_LAUNCH_CAMERA && intent?.getBooleanExtra(BY_QR_CODE, false)!!) { + } else if (requestCode == PERMISSION_REQUEST_CODE_LAUNCH_CAMERA) { addFragment(R.id.container, CreateDirectRoomByQrCodeFragment::class.java) } } else { Toast.makeText(baseContext, R.string.missing_permissions_error, Toast.LENGTH_SHORT).show() - if (requestCode == PERMISSION_REQUEST_CODE_LAUNCH_CAMERA && intent?.getBooleanExtra(BY_QR_CODE, false)!!) { + if (requestCode == PERMISSION_REQUEST_CODE_LAUNCH_CAMERA) { finish() } } } - private fun onMenuItemSelected(action: UserDirectorySharedAction.OnMenuItemSelected) { + private fun onMenuItemSelected(action: UserListSharedAction.OnMenuItemSelected) { if (action.itemId == R.id.action_create_direct_room) { viewModel.handle(CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers( action.invitees, - action.existingDmRoomId + null )) } } @@ -198,12 +197,9 @@ class CreateDirectRoomActivity : SimpleFragmentActivity() { } companion object { - private const val BY_QR_CODE = "BY_QR_CODE" - fun getIntent(context: Context, byQrCode: Boolean = false): Intent { - return Intent(context, CreateDirectRoomActivity::class.java).apply { - putExtra(BY_QR_CODE, byQrCode) - } + fun getIntent(context: Context): Intent { + return Intent(context, CreateDirectRoomActivity::class.java) } } } diff --git a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomByQrCodeFragment.kt b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomByQrCodeFragment.kt index f03368fdd5..3fee3a3285 100644 --- a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomByQrCodeFragment.kt +++ b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomByQrCodeFragment.kt @@ -21,7 +21,11 @@ import com.airbnb.mvrx.activityViewModel import com.google.zxing.Result import com.google.zxing.ResultMetadataType import im.vector.app.R +import im.vector.app.core.extensions.hideKeyboard import im.vector.app.core.platform.VectorBaseFragment +import im.vector.app.core.utils.PERMISSIONS_FOR_TAKING_PHOTO +import im.vector.app.core.utils.checkPermissions +import im.vector.app.core.utils.registerForPermissionsResult import im.vector.app.features.userdirectory.PendingInvitee import kotlinx.android.synthetic.main.fragment_qr_code_scanner.* import me.dm7.barcodescanner.zxing.ZXingScannerView @@ -36,16 +40,32 @@ class CreateDirectRoomByQrCodeFragment @Inject constructor() : VectorBaseFragmen override fun getLayoutResId() = R.layout.fragment_qr_code_scanner - override fun onResume() { - super.onResume() - // Register ourselves as a handler for scan results. - scannerView.setResultHandler(null) + private val openCameraActivityResultLauncher = registerForPermissionsResult { allGranted -> + if (allGranted) { + startCamera() + } + } + + private fun startCamera() { // Start camera on resume scannerView.startCamera() } + override fun onResume() { + super.onResume() + view?.hideKeyboard() + // Register ourselves as a handler for scan results. + scannerView.setResultHandler(this) + // Start camera on resume + if (checkPermissions(PERMISSIONS_FOR_TAKING_PHOTO, requireActivity(), openCameraActivityResultLauncher)) { + startCamera() + } + } + override fun onPause() { super.onPause() + // Unregister ourselves as a handler for scan results. + scannerView.setResultHandler(null) // Stop camera on pause scannerView.stopCamera() } @@ -73,23 +93,17 @@ class CreateDirectRoomByQrCodeFragment @Inject constructor() : VectorBaseFragmen requireActivity().finish() } else { val existingDm = viewModel.session.getExistingDirectRoomWithUser(mxid) - - if (existingDm === null) { - // The following assumes MXIDs are case insensitive - if (mxid.equals(other = viewModel.session.myUserId, ignoreCase = true)) { - Toast.makeText(requireContext(), R.string.cannot_dm_self, Toast.LENGTH_SHORT).show() - requireActivity().finish() - } else { - // Try to get user from known users and fall back to creating a User object from MXID - val qrInvitee = if (viewModel.session.getUser(mxid) != null) viewModel.session.getUser(mxid)!! else User(mxid, null, null) - - viewModel.handle( - CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers(setOf(PendingInvitee.UserPendingInvitee(qrInvitee))) - ) - } - } else { - navigator.openRoom(requireContext(), existingDm, null, false) + // The following assumes MXIDs are case insensitive + if (mxid.equals(other = viewModel.session.myUserId, ignoreCase = true)) { + Toast.makeText(requireContext(), R.string.cannot_dm_self, Toast.LENGTH_SHORT).show() requireActivity().finish() + } else { + // Try to get user from known users and fall back to creating a User object from MXID + val qrInvitee = if (viewModel.session.getUser(mxid) != null) viewModel.session.getUser(mxid)!! else User(mxid, null, null) + + viewModel.handle( + CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers(setOf(PendingInvitee.UserPendingInvitee(qrInvitee)), existingDm) + ) } } } diff --git a/vector/src/main/java/im/vector/app/features/home/HomeDrawerFragment.kt b/vector/src/main/java/im/vector/app/features/home/HomeDrawerFragment.kt index e267248fc3..1a60d8e219 100644 --- a/vector/src/main/java/im/vector/app/features/home/HomeDrawerFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/HomeDrawerFragment.kt @@ -18,15 +18,19 @@ package im.vector.app.features.home import android.os.Bundle import android.view.View +import androidx.core.app.ActivityOptionsCompat +import androidx.core.view.ViewCompat import androidx.core.view.isVisible import im.vector.app.BuildConfig import im.vector.app.R import im.vector.app.core.extensions.observeK import im.vector.app.core.extensions.replaceChildFragment import im.vector.app.core.platform.VectorBaseFragment +import im.vector.app.core.utils.startSharePlainTextIntent import im.vector.app.features.grouplist.GroupListFragment import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.settings.VectorSettingsActivity +import im.vector.app.features.usercode.UserCodeActivity import im.vector.app.features.workers.signout.SignOutUiWorker import kotlinx.android.synthetic.main.fragment_home_drawer.* import org.matrix.android.sdk.api.session.Session @@ -75,6 +79,32 @@ class HomeDrawerFragment @Inject constructor( SignOutUiWorker(requireActivity()).perform() } + homeDrawerQRCodeButton.debouncedClicks { + UserCodeActivity.newIntent(requireContext(), sharedActionViewModel.session.myUserId).let { + val options = + ActivityOptionsCompat.makeSceneTransitionAnimation( + requireActivity(), + homeDrawerHeaderAvatarView, + ViewCompat.getTransitionName(homeDrawerHeaderAvatarView) ?: "" + ) + startActivity(it, options.toBundle()) + } + } + + homeDrawerInviteFriendButton.debouncedClicks { + session.permalinkService().createPermalink(sharedActionViewModel.session.myUserId)?.let { permalink -> + val text = getString(R.string.invite_friends_text, permalink) + + startSharePlainTextIntent( + fragment = this, + activityResultLauncher = null, + chooserTitle = getString(R.string.invite_friends), + text = text, + extraTitle = getString(R.string.invite_friends_rich_title) + ) + } + } + // Debug menu homeDrawerHeaderDebugView.isVisible = BuildConfig.DEBUG && vectorPreferences.developerMode() homeDrawerHeaderDebugView.debouncedClicks { diff --git a/vector/src/main/java/im/vector/app/features/home/HomeSharedActionViewModel.kt b/vector/src/main/java/im/vector/app/features/home/HomeSharedActionViewModel.kt index 58747a4c18..b695f48ee5 100644 --- a/vector/src/main/java/im/vector/app/features/home/HomeSharedActionViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/HomeSharedActionViewModel.kt @@ -17,6 +17,7 @@ package im.vector.app.features.home import im.vector.app.core.platform.VectorSharedActionViewModel +import org.matrix.android.sdk.api.session.Session import javax.inject.Inject -class HomeSharedActionViewModel @Inject constructor() : VectorSharedActionViewModel() +class HomeSharedActionViewModel @Inject constructor(val session: Session) : VectorSharedActionViewModel() diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListFragment.kt index e47072d0b0..60c2745b44 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListFragment.kt @@ -45,7 +45,6 @@ import im.vector.app.features.home.room.list.actions.RoomListActionsArgs import im.vector.app.features.home.room.list.actions.RoomListQuickActionsBottomSheet import im.vector.app.features.home.room.list.actions.RoomListQuickActionsSharedAction import im.vector.app.features.home.room.list.actions.RoomListQuickActionsSharedActionViewModel -import im.vector.app.features.home.room.list.widget.DmsFabMenuView import im.vector.app.features.home.room.list.widget.NotifsFabMenuView import im.vector.app.features.notifications.NotificationDrawerManager import kotlinx.android.parcel.Parcelize @@ -67,7 +66,7 @@ class RoomListFragment @Inject constructor( val roomListViewModelFactory: RoomListViewModel.Factory, private val notificationDrawerManager: NotificationDrawerManager, private val sharedViewPool: RecyclerView.RecycledViewPool -) : VectorBaseFragment(), RoomSummaryController.Listener, OnBackPressed, DmsFabMenuView.Listener, NotifsFabMenuView.Listener { +) : VectorBaseFragment(), RoomSummaryController.Listener, OnBackPressed, NotifsFabMenuView.Listener { private var modelBuildListener: OnModelBuildFinishedListener? = null private lateinit var sharedActionViewModel: RoomListQuickActionsSharedActionViewModel @@ -111,7 +110,6 @@ class RoomListFragment @Inject constructor( }.exhaustive } - createDmFabMenu.listener = this createChatFabMenu.listener = this sharedActionViewModel @@ -130,7 +128,6 @@ class RoomListFragment @Inject constructor( roomListView.cleanup() roomController.listener = null stateRestorer.clear() - createDmFabMenu.listener = null createChatFabMenu.listener = null super.onDestroyView() } @@ -142,32 +139,33 @@ class RoomListFragment @Inject constructor( private fun setupCreateRoomButton() { when (roomListParams.displayMode) { RoomListDisplayMode.NOTIFICATIONS -> createChatFabMenu.isVisible = true - RoomListDisplayMode.PEOPLE -> createDmFabMenu.isVisible = true + RoomListDisplayMode.PEOPLE -> createChatRoomButton.isVisible = true RoomListDisplayMode.ROOMS -> createGroupRoomButton.isVisible = true else -> Unit // No button in this mode } + createChatRoomButton.debouncedClicks { + createDirectChat() + } createGroupRoomButton.debouncedClicks { openRoomDirectory() } - // Hide FABs when list is scrolling + // Hide FAB when list is scrolling roomListView.addOnScrollListener( object : RecyclerView.OnScrollListener() { override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { - createDmFabMenu.removeCallbacks(showFabRunnable) createChatFabMenu.removeCallbacks(showFabRunnable) when (newState) { RecyclerView.SCROLL_STATE_IDLE -> { - createDmFabMenu.postDelayed(showFabRunnable, 250) createChatFabMenu.postDelayed(showFabRunnable, 250) } RecyclerView.SCROLL_STATE_DRAGGING, RecyclerView.SCROLL_STATE_SETTLING -> { when (roomListParams.displayMode) { RoomListDisplayMode.NOTIFICATIONS -> createChatFabMenu.hide() - RoomListDisplayMode.PEOPLE -> createDmFabMenu.hide() + RoomListDisplayMode.PEOPLE -> createChatRoomButton.hide() RoomListDisplayMode.ROOMS -> createGroupRoomButton.hide() else -> Unit } @@ -192,10 +190,6 @@ class RoomListFragment @Inject constructor( navigator.openCreateDirectRoom(requireActivity()) } - override fun createDirectChatByQrCode() { - navigator.openCreateDirectRoom(requireContext(), true) - } - private fun setupRecyclerView() { val layoutManager = LinearLayoutManager(context) stateRestorer = LayoutManagerStateRestorer(layoutManager).register() @@ -214,7 +208,7 @@ class RoomListFragment @Inject constructor( if (isAdded) { when (roomListParams.displayMode) { RoomListDisplayMode.NOTIFICATIONS -> createChatFabMenu.show() - RoomListDisplayMode.PEOPLE -> createDmFabMenu.show() + RoomListDisplayMode.PEOPLE -> createChatRoomButton.show() RoomListDisplayMode.ROOMS -> createGroupRoomButton.show() else -> Unit } @@ -343,9 +337,6 @@ class RoomListFragment @Inject constructor( } override fun onBackPressed(toolbarButton: Boolean): Boolean { - if (createDmFabMenu.onBackPressed()) { - return true - } if (createChatFabMenu.onBackPressed()) { return true } diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/widget/DmsFabMenuView.kt b/vector/src/main/java/im/vector/app/features/home/room/list/widget/DmsFabMenuView.kt deleted file mode 100644 index 9659b7b12b..0000000000 --- a/vector/src/main/java/im/vector/app/features/home/room/list/widget/DmsFabMenuView.kt +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Copyright 2019 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.home.room.list.widget - -import android.content.Context -import android.util.AttributeSet -import androidx.constraintlayout.motion.widget.MotionLayout -import androidx.core.view.isVisible -import com.google.android.material.floatingactionbutton.FloatingActionButton -import im.vector.app.R -import kotlinx.android.synthetic.main.motion_dms_fab_menu_merge.view.* - -class DmsFabMenuView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, - defStyleAttr: Int = 0) : MotionLayout(context, attrs, defStyleAttr) { - - var listener: Listener? = null - - init { - inflate(context, R.layout.motion_dms_fab_menu_merge, this) - } - - override fun onFinishInflate() { - super.onFinishInflate() - - listOf(createDmByMxid, createDmByMxidLabel) - .forEach { - it.setOnClickListener { - closeFabMenu() - listener?.createDirectChat() - } - } - listOf(createDmByQrCode, createDmByQrCodeLabel) - .forEach { - it.setOnClickListener { - closeFabMenu() - listener?.createDirectChatByQrCode() - } - } - - dmsCreateRoomTouchGuard.setOnClickListener { - closeFabMenu() - } - } - - override fun transitionToEnd() { - super.transitionToEnd() - - dmsCreateRoomButton.contentDescription = context.getString(R.string.a11y_create_menu_close) - } - - override fun transitionToStart() { - super.transitionToStart() - - dmsCreateRoomButton.contentDescription = context.getString(R.string.a11y_create_menu_open) - } - - fun show() { - isVisible = true - dmsCreateRoomButton.show() - } - - fun hide() { - dmsCreateRoomButton.hide(object : FloatingActionButton.OnVisibilityChangedListener() { - override fun onHidden(fab: FloatingActionButton?) { - super.onHidden(fab) - isVisible = false - } - }) - } - - private fun closeFabMenu() { - transitionToStart() - } - - fun onBackPressed(): Boolean { - if (currentState == R.id.constraint_set_fab_menu_open) { - closeFabMenu() - return true - } - - return false - } - - interface Listener { - fun createDirectChat() - fun createDirectChatByQrCode() - } -} diff --git a/vector/src/main/java/im/vector/app/features/homeserver/HomeServerCapabilitiesViewModel.kt b/vector/src/main/java/im/vector/app/features/homeserver/HomeServerCapabilitiesViewModel.kt index f6c60f6a6d..fe7a8006e0 100644 --- a/vector/src/main/java/im/vector/app/features/homeserver/HomeServerCapabilitiesViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/homeserver/HomeServerCapabilitiesViewModel.kt @@ -28,7 +28,7 @@ import im.vector.app.core.platform.EmptyViewEvents import im.vector.app.core.platform.VectorViewModel import im.vector.app.features.raw.wellknown.getElementWellknown import im.vector.app.features.raw.wellknown.isE2EByDefault -import im.vector.app.features.userdirectory.KnownUsersFragment +import im.vector.app.features.userdirectory.UserListFragment import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.matrix.android.sdk.api.extensions.tryOrNull @@ -50,7 +50,7 @@ class HomeServerCapabilitiesViewModel @AssistedInject constructor( companion object : MvRxViewModelFactory { @JvmStatic override fun create(viewModelContext: ViewModelContext, state: HomeServerCapabilitiesViewState): HomeServerCapabilitiesViewModel? { - val fragment: KnownUsersFragment = (viewModelContext as FragmentViewModelContext).fragment() + val fragment: UserListFragment = (viewModelContext as FragmentViewModelContext).fragment() return fragment.homeServerCapabilitiesViewModelFactory.create(state) } diff --git a/vector/src/main/java/im/vector/app/features/invite/InviteUsersToRoomActivity.kt b/vector/src/main/java/im/vector/app/features/invite/InviteUsersToRoomActivity.kt index 087b7c2f55..513fbb5d83 100644 --- a/vector/src/main/java/im/vector/app/features/invite/InviteUsersToRoomActivity.kt +++ b/vector/src/main/java/im/vector/app/features/invite/InviteUsersToRoomActivity.kt @@ -21,6 +21,7 @@ import android.content.Intent import android.os.Bundle import android.os.Parcelable import android.view.View +import android.widget.Toast import androidx.appcompat.app.AlertDialog import com.airbnb.mvrx.MvRx import com.airbnb.mvrx.viewModel @@ -29,7 +30,6 @@ import im.vector.app.core.di.ScreenComponent import im.vector.app.core.error.ErrorFormatter import im.vector.app.core.extensions.addFragment import im.vector.app.core.extensions.addFragmentToBackstack -import im.vector.app.core.extensions.exhaustive import im.vector.app.core.platform.SimpleFragmentActivity import im.vector.app.core.platform.WaitingViewData import im.vector.app.core.utils.PERMISSIONS_FOR_MEMBERS_SEARCH @@ -39,12 +39,12 @@ import im.vector.app.core.utils.checkPermissions import im.vector.app.core.utils.toast import im.vector.app.features.contactsbook.ContactsBookFragment import im.vector.app.features.contactsbook.ContactsBookViewModel -import im.vector.app.features.userdirectory.KnownUsersFragment -import im.vector.app.features.userdirectory.KnownUsersFragmentArgs -import im.vector.app.features.userdirectory.UserDirectoryFragment -import im.vector.app.features.userdirectory.UserDirectorySharedAction -import im.vector.app.features.userdirectory.UserDirectorySharedActionViewModel -import im.vector.app.features.userdirectory.UserDirectoryViewModel +import im.vector.app.features.userdirectory.UserListFragment +import im.vector.app.features.userdirectory.UserListFragmentArgs +import im.vector.app.features.userdirectory.UserListSharedAction +import im.vector.app.features.userdirectory.UserListSharedActionViewModel +import im.vector.app.features.userdirectory.UserListViewModel +import im.vector.app.features.userdirectory.UserListViewState import kotlinx.android.parcel.Parcelize import kotlinx.android.synthetic.main.activity.* import org.matrix.android.sdk.api.failure.Failure @@ -54,11 +54,11 @@ import javax.inject.Inject @Parcelize data class InviteUsersToRoomArgs(val roomId: String) : Parcelable -class InviteUsersToRoomActivity : SimpleFragmentActivity() { +class InviteUsersToRoomActivity : SimpleFragmentActivity(), UserListViewModel.Factory { private val viewModel: InviteUsersToRoomViewModel by viewModel() - private lateinit var sharedActionViewModel: UserDirectorySharedActionViewModel - @Inject lateinit var userDirectoryViewModelFactory: UserDirectoryViewModel.Factory + private lateinit var sharedActionViewModel: UserListSharedActionViewModel + @Inject lateinit var userListViewModelFactory: UserListViewModel.Factory @Inject lateinit var inviteUsersToRoomViewModelFactory: InviteUsersToRoomViewModel.Factory @Inject lateinit var contactsBookViewModelFactory: ContactsBookViewModel.Factory @Inject lateinit var errorFormatter: ErrorFormatter @@ -68,32 +68,40 @@ class InviteUsersToRoomActivity : SimpleFragmentActivity() { injector.inject(this) } + override fun create(initialState: UserListViewState, args: UserListFragmentArgs): UserListViewModel { + return userListViewModelFactory.create(initialState, args) + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) toolbar.visibility = View.GONE - sharedActionViewModel = viewModelProvider.get(UserDirectorySharedActionViewModel::class.java) + + sharedActionViewModel = viewModelProvider.get(UserListSharedActionViewModel::class.java) sharedActionViewModel .observe() .subscribe { sharedAction -> when (sharedAction) { - UserDirectorySharedAction.OpenUsersDirectory -> - addFragmentToBackstack(R.id.container, UserDirectoryFragment::class.java) - UserDirectorySharedAction.Close -> finish() - UserDirectorySharedAction.GoBack -> onBackPressed() - is UserDirectorySharedAction.OnMenuItemSelected -> onMenuItemSelected(sharedAction) - UserDirectorySharedAction.OpenPhoneBook -> openPhoneBook() - }.exhaustive + UserListSharedAction.Close -> finish() + UserListSharedAction.GoBack -> onBackPressed() + is UserListSharedAction.OnMenuItemSelected -> onMenuItemSelected(sharedAction) + UserListSharedAction.OpenPhoneBook -> openPhoneBook() + // not exhaustive because it's a sharedAction + else -> { + } + } } .disposeOnDestroy() if (isFirstCreation()) { + val args: InviteUsersToRoomArgs? = intent.extras?.getParcelable(MvRx.KEY_ARG) addFragment( R.id.container, - KnownUsersFragment::class.java, - KnownUsersFragmentArgs( + UserListFragment::class.java, + UserListFragmentArgs( title = getString(R.string.invite_users_to_room_title), menuResId = R.menu.vector_invite_users_to_room, - excludedUserIds = viewModel.getUserIdsOfRoomMembers() + excludedUserIds = viewModel.getUserIdsOfRoomMembers(), + existingRoomId = args?.roomId ) ) } @@ -101,6 +109,12 @@ class InviteUsersToRoomActivity : SimpleFragmentActivity() { viewModel.observeViewEvents { renderInviteEvents(it) } } + private fun onMenuItemSelected(action: UserListSharedAction.OnMenuItemSelected) { + if (action.itemId == R.id.action_invite_users_to_room_invite) { + viewModel.handle(InviteUsersToRoomAction.InviteSelectedUsers(action.invitees)) + } + } + private fun openPhoneBook() { // Check permission first if (checkPermissions(PERMISSIONS_FOR_MEMBERS_SEARCH, @@ -117,12 +131,8 @@ class InviteUsersToRoomActivity : SimpleFragmentActivity() { if (requestCode == PERMISSION_REQUEST_CODE_READ_CONTACTS) { doOnPostResume { addFragmentToBackstack(R.id.container, ContactsBookFragment::class.java) } } - } - } - - private fun onMenuItemSelected(action: UserDirectorySharedAction.OnMenuItemSelected) { - if (action.itemId == R.id.action_invite_users_to_room_invite) { - viewModel.handle(InviteUsersToRoomAction.InviteSelectedUsers(action.invitees)) + } else { + Toast.makeText(baseContext, R.string.missing_permissions_error, Toast.LENGTH_SHORT).show() } } diff --git a/vector/src/main/java/im/vector/app/features/matrixto/MatrixToBottomSheet.kt b/vector/src/main/java/im/vector/app/features/matrixto/MatrixToBottomSheet.kt new file mode 100644 index 0000000000..3f3706699f --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/matrixto/MatrixToBottomSheet.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2020 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.matrixto + +import android.os.Bundle +import android.view.View +import im.vector.app.R +import im.vector.app.core.di.ScreenComponent +import im.vector.app.core.extensions.setTextOrHide +import im.vector.app.core.platform.VectorBaseBottomSheetDialogFragment +import im.vector.app.features.home.AvatarRenderer +import kotlinx.android.synthetic.main.fragment_matrix_to_card.* +import org.matrix.android.sdk.api.util.MatrixItem +import javax.inject.Inject + +class MatrixToBottomSheet(private val matrixItem: MatrixItem) : VectorBaseBottomSheetDialogFragment() { + + @Inject lateinit var avatarRenderer: AvatarRenderer + + interface InteractionListener { + fun didTapStartMessage(matrixItem: MatrixItem) + } + + override fun injectWith(injector: ScreenComponent) { + injector.inject(this) + } + + private var interactionListener: InteractionListener? = null + + override fun getLayoutResId() = R.layout.fragment_matrix_to_card + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + matrixToCardSendMessageButton.debouncedClicks { + interactionListener?.didTapStartMessage(matrixItem) + dismiss() + } + + matrixToCardNameText.setTextOrHide(matrixItem.displayName) + matrixToCardUserIdText.setTextOrHide(matrixItem.id) + avatarRenderer.render(matrixItem, matrixToCardAvatar) + } + + companion object { + const val ARGS = "MatrixToFragment.Args" + + fun create(matrixItem: MatrixItem, listener: InteractionListener?): MatrixToBottomSheet { + return MatrixToBottomSheet(matrixItem).apply { + interactionListener = listener + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt b/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt index 9ff103113f..2d0ca86d52 100644 --- a/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt +++ b/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt @@ -203,8 +203,8 @@ class DefaultNavigator @Inject constructor( context.startActivity(intent) } - override fun openCreateDirectRoom(context: Context, byQrCode: Boolean) { - val intent = CreateDirectRoomActivity.getIntent(context, byQrCode) + override fun openCreateDirectRoom(context: Context) { + val intent = CreateDirectRoomActivity.getIntent(context) context.startActivity(intent) } diff --git a/vector/src/main/java/im/vector/app/features/navigation/Navigator.kt b/vector/src/main/java/im/vector/app/features/navigation/Navigator.kt index 23d24b709c..504fccb63a 100644 --- a/vector/src/main/java/im/vector/app/features/navigation/Navigator.kt +++ b/vector/src/main/java/im/vector/app/features/navigation/Navigator.kt @@ -56,7 +56,7 @@ interface Navigator { fun openCreateRoom(context: Context, initialName: String = "") - fun openCreateDirectRoom(context: Context, byQrCode: Boolean = false) + fun openCreateDirectRoom(context: Context) fun openInviteUsersToRoom(context: Context, roomId: String) diff --git a/vector/src/main/java/im/vector/app/features/roommemberprofile/RoomMemberProfileController.kt b/vector/src/main/java/im/vector/app/features/roommemberprofile/RoomMemberProfileController.kt index 2e91091443..e29c197ab8 100644 --- a/vector/src/main/java/im/vector/app/features/roommemberprofile/RoomMemberProfileController.kt +++ b/vector/src/main/java/im/vector/app/features/roommemberprofile/RoomMemberProfileController.kt @@ -79,6 +79,17 @@ class RoomMemberProfileController @Inject constructor( divider = false, action = { callback?.onIgnoreClicked() } ) + if (!state.isMine) { + buildProfileSection(stringProvider.getString(R.string.room_profile_section_more)) + + buildProfileAction( + id = "direct", + editable = false, + title = stringProvider.getString(R.string.room_member_open_or_create_dm), + dividerColor = dividerColor, + action = { callback?.onOpenDmClicked() } + ) + } } private fun buildRoomMemberActions(state: RoomMemberProfileViewState) { diff --git a/vector/src/main/java/im/vector/app/features/usercode/QRCodeBitmapDecodeHelper.kt b/vector/src/main/java/im/vector/app/features/usercode/QRCodeBitmapDecodeHelper.kt new file mode 100644 index 0000000000..178a283d2c --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/usercode/QRCodeBitmapDecodeHelper.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2020 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.usercode + +import android.graphics.Bitmap +import com.google.zxing.BarcodeFormat +import com.google.zxing.BinaryBitmap +import com.google.zxing.DecodeHintType +import com.google.zxing.LuminanceSource +import com.google.zxing.MultiFormatReader +import com.google.zxing.RGBLuminanceSource +import com.google.zxing.ReaderException +import com.google.zxing.Result +import com.google.zxing.common.HybridBinarizer + +// Some helper code from BinaryEye +object QRCodeBitmapDecodeHelper { + + private val multiFormatReader = MultiFormatReader() + private val decoderHints = mapOf(DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE)) + + fun decodeQRFromBitmap(bitmap: Bitmap): Result? = + decode(bitmap, false) ?: decode(bitmap, true) + + private fun decode(bitmap: Bitmap, invert: Boolean = false): Result? { + val pixels = IntArray(bitmap.width * bitmap.height) + return decode(pixels, bitmap, invert) + } + + private fun decode( + pixels: IntArray, + bitmap: Bitmap, + invert: Boolean = false + ): Result? { + val width = bitmap.width + val height = bitmap.height + if (bitmap.config != Bitmap.Config.ARGB_8888) { + bitmap.copy(Bitmap.Config.ARGB_8888, true) + } else { + bitmap + }.getPixels(pixels, 0, width, 0, 0, width, height) + return decodeLuminanceSource( + RGBLuminanceSource(width, height, pixels), + invert + ) + } + + private fun decodeLuminanceSource( + source: LuminanceSource, + invert: Boolean + ): Result? { + return decodeLuminanceSource( + if (invert) { + source.invert() + } else { + source + } + ) + } + + private fun decodeLuminanceSource(source: LuminanceSource): Result? { + val bitmap = BinaryBitmap(HybridBinarizer(source)) + return try { + multiFormatReader.decode(bitmap, decoderHints) + } catch (e: ReaderException) { + null + } finally { + multiFormatReader.reset() + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/usercode/ScanUserCodeFragment.kt b/vector/src/main/java/im/vector/app/features/usercode/ScanUserCodeFragment.kt new file mode 100644 index 0000000000..8b4820b06d --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/usercode/ScanUserCodeFragment.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2020 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.usercode + +import android.app.Activity +import android.os.Bundle +import android.view.View +import android.widget.Toast +import com.airbnb.mvrx.activityViewModel +import com.google.zxing.Result +import com.google.zxing.ResultMetadataType +import im.vector.app.R +import im.vector.app.core.extensions.registerStartForActivityResult +import im.vector.app.core.platform.VectorBaseFragment +import im.vector.app.core.utils.PERMISSIONS_FOR_TAKING_PHOTO +import im.vector.app.core.utils.checkPermissions +import im.vector.app.core.utils.registerForPermissionsResult +import im.vector.lib.multipicker.MultiPicker +import im.vector.lib.multipicker.utils.ImageUtils +import kotlinx.android.synthetic.main.fragment_qr_code_scanner_with_button.* +import me.dm7.barcodescanner.zxing.ZXingScannerView +import org.matrix.android.sdk.api.extensions.tryOrNull +import javax.inject.Inject + +class ScanUserCodeFragment @Inject constructor() + : VectorBaseFragment(), + ZXingScannerView.ResultHandler { + + override fun getLayoutResId() = R.layout.fragment_qr_code_scanner_with_button + + val sharedViewModel: UserCodeSharedViewModel by activityViewModel() + + var autoFocus = true + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + userCodeMyCodeButton.debouncedClicks { + sharedViewModel.handle(UserCodeActions.SwitchMode(UserCodeState.Mode.SHOW)) + } + + userCodeOpenGalleryButton.debouncedClicks { + MultiPicker.get(MultiPicker.IMAGE).single().startWith(pickImageActivityResultLauncher) + } + } + + private val openCameraActivityResultLauncher = registerForPermissionsResult { allGranted -> + if (allGranted) { + startCamera() + } + } + + private val pickImageActivityResultLauncher = registerStartForActivityResult { activityResult -> + if (activityResult.resultCode == Activity.RESULT_OK) { + MultiPicker + .get(MultiPicker.IMAGE) + .getSelectedFiles(requireActivity(), activityResult.data) + .firstOrNull() + ?.contentUri + ?.let { uri -> + // try to see if it is a valid matrix code + val bitmap = ImageUtils.getBitmap(requireContext(), uri) + ?: return@let Unit.also { + Toast.makeText(requireContext(), getString(R.string.qr_code_not_scanned), Toast.LENGTH_SHORT).show() + } + handleResult(tryOrNull { QRCodeBitmapDecodeHelper.decodeQRFromBitmap(bitmap) }) + } + } + } + + private fun startCamera() { + userCodeScannerView.startCamera() + userCodeScannerView.setAutoFocus(autoFocus) + userCodeScannerView.debouncedClicks { + this.autoFocus = !autoFocus + userCodeScannerView.setAutoFocus(autoFocus) + } + } + + override fun onResume() { + super.onResume() + // Register ourselves as a handler for scan results. + userCodeScannerView.setResultHandler(this) + // Start camera on resume + if (checkPermissions(PERMISSIONS_FOR_TAKING_PHOTO, requireActivity(), openCameraActivityResultLauncher)) { + startCamera() + } + } + + override fun onPause() { + super.onPause() + // Stop camera on pause + userCodeScannerView.stopCamera() + } + + override fun handleResult(result: Result?) { + if (result === null) { + Toast.makeText(requireContext(), R.string.qr_code_not_scanned, Toast.LENGTH_SHORT).show() + requireActivity().finish() + } else { + val rawBytes = getRawBytes(result) + val rawBytesStr = rawBytes?.toString(Charsets.ISO_8859_1) + val value = rawBytesStr ?: result.text + sharedViewModel.handle(UserCodeActions.DecodedQRCode(value)) + } + } + + // Copied from https://github.com/markusfisch/BinaryEye/blob/ + // 9d57889b810dcaa1a91d7278fc45c262afba1284/app/src/main/kotlin/de/markusfisch/android/binaryeye/activity/CameraActivity.kt#L434 + private fun getRawBytes(result: Result): ByteArray? { + val metadata = result.resultMetadata ?: return null + val segments = metadata[ResultMetadataType.BYTE_SEGMENTS] ?: return null + var bytes = ByteArray(0) + @Suppress("UNCHECKED_CAST") + for (seg in segments as Iterable) { + bytes += seg + } + // byte segments can never be shorter than the text. + // Zxing cuts off content prefixes like "WIFI:" + return if (bytes.size >= result.text.length) bytes else null + } +} diff --git a/vector/src/main/java/im/vector/app/features/usercode/ShowUserCodeFragment.kt b/vector/src/main/java/im/vector/app/features/usercode/ShowUserCodeFragment.kt new file mode 100644 index 0000000000..ab88f79bef --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/usercode/ShowUserCodeFragment.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2020 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.usercode + +import android.os.Bundle +import android.view.View +import com.airbnb.mvrx.activityViewModel +import com.airbnb.mvrx.withState +import im.vector.app.R +import im.vector.app.core.extensions.setTextOrHide +import im.vector.app.core.platform.VectorBaseFragment +import im.vector.app.features.home.AvatarRenderer +import kotlinx.android.synthetic.main.fragment_user_code_show.* +import javax.inject.Inject + +class ShowUserCodeFragment @Inject constructor( + private val avatarRenderer: AvatarRenderer +) : VectorBaseFragment() { + + override fun getLayoutResId() = R.layout.fragment_user_code_show + + val sharedViewModel: UserCodeSharedViewModel by activityViewModel() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + showUserCodeClose.debouncedClicks { + sharedViewModel.handle(UserCodeActions.DismissAction) + } + showUserCodeScanButton.debouncedClicks { + doOpenQRCodeScanner() + } + } + + private fun doOpenQRCodeScanner() { + sharedViewModel.handle(UserCodeActions.SwitchMode(UserCodeState.Mode.SCAN)) + } + + override fun invalidate() = withState(sharedViewModel) { state -> + state.matrixItem?.let { avatarRenderer.render(it, showUserCodeAvatar) } + state.shareLink?.let { showUserCodeQRImage.setData(it) } + showUserCodeCardNameText.setTextOrHide(state.matrixItem?.displayName) + showUserCodeCardUserIdText.setTextOrHide(state.matrixItem?.id) + Unit + } +} diff --git a/vector/src/main/java/im/vector/app/features/usercode/UserCodeActions.kt b/vector/src/main/java/im/vector/app/features/usercode/UserCodeActions.kt new file mode 100644 index 0000000000..0611e0f8c3 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/usercode/UserCodeActions.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2020 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.usercode + +import im.vector.app.core.platform.VectorViewModelAction +import org.matrix.android.sdk.api.util.MatrixItem + +sealed class UserCodeActions : VectorViewModelAction { + object DismissAction : UserCodeActions() + data class SwitchMode(val mode: UserCodeState.Mode) : UserCodeActions() + data class DecodedQRCode(val code: String) : UserCodeActions() + data class StartChattingWithUser(val matrixItem: MatrixItem) : UserCodeActions() +} diff --git a/vector/src/main/java/im/vector/app/features/usercode/UserCodeActivity.kt b/vector/src/main/java/im/vector/app/features/usercode/UserCodeActivity.kt new file mode 100644 index 0000000000..388dc220a8 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/usercode/UserCodeActivity.kt @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2020 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.usercode + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.os.Parcelable +import android.widget.Toast +import androidx.core.app.ActivityCompat +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import com.airbnb.mvrx.MvRx +import com.airbnb.mvrx.viewModel +import com.airbnb.mvrx.withState +import im.vector.app.R +import im.vector.app.core.di.ScreenComponent +import im.vector.app.core.extensions.exhaustive +import im.vector.app.core.platform.VectorBaseActivity +import im.vector.app.features.matrixto.MatrixToBottomSheet +import kotlinx.android.parcel.Parcelize +import kotlinx.android.synthetic.main.activity_simple.* +import org.matrix.android.sdk.api.util.MatrixItem +import javax.inject.Inject +import kotlin.reflect.KClass + +class UserCodeActivity + : VectorBaseActivity(), UserCodeSharedViewModel.Factory, MatrixToBottomSheet.InteractionListener { + + @Inject lateinit var viewModelFactory: UserCodeSharedViewModel.Factory + + val sharedViewModel: UserCodeSharedViewModel by viewModel() + + @Parcelize + data class Args( + val userId: String + ) : Parcelable + + override fun getLayoutRes() = R.layout.activity_simple + + override fun injectWith(injector: ScreenComponent) { + injector.inject(this) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (isFirstCreation()) { + // should be there early for shared element transition + showFragment(ShowUserCodeFragment::class, Bundle.EMPTY) + } + + sharedViewModel.selectSubscribe(this, UserCodeState::mode) { mode -> + when (mode) { + UserCodeState.Mode.SHOW -> showFragment(ShowUserCodeFragment::class, Bundle.EMPTY) + UserCodeState.Mode.SCAN -> showFragment(ScanUserCodeFragment::class, Bundle.EMPTY) + is UserCodeState.Mode.RESULT -> { + showFragment(ShowUserCodeFragment::class, Bundle.EMPTY) + MatrixToBottomSheet.create(mode.matrixItem, this).show(supportFragmentManager, "MatrixToBottomSheet") + } + } + } + + sharedViewModel.observeViewEvents { + when (it) { + is UserCodeShareViewEvents.InviteFriend -> TODO() + UserCodeShareViewEvents.Dismiss -> ActivityCompat.finishAfterTransition(this) + UserCodeShareViewEvents.ShowWaitingScreen -> simpleActivityWaitingView.isVisible = true + UserCodeShareViewEvents.HideWaitingScreen -> simpleActivityWaitingView.isVisible = false + is UserCodeShareViewEvents.ToastMessage -> Toast.makeText(this, it.message, Toast.LENGTH_LONG).show() + is UserCodeShareViewEvents.NavigateToRoom -> navigator.openRoom(this, it.roomId) + }.exhaustive + } + } + + private fun showFragment(fragmentClass: KClass, bundle: Bundle) { + if (supportFragmentManager.findFragmentByTag(fragmentClass.simpleName) == null) { + supportFragmentManager.beginTransaction().let { + it.setCustomAnimations(R.anim.fade_in, R.anim.fade_out, R.anim.fade_in, R.anim.fade_out) + it.replace(R.id.simpleFragmentContainer, + fragmentClass.java, + bundle, + fragmentClass.simpleName + ) + it.commit() + } + } + } + + override fun didTapStartMessage(matrixItem: MatrixItem) { + sharedViewModel.handle(UserCodeActions.StartChattingWithUser(matrixItem)) + } + + override fun onBackPressed() = withState(sharedViewModel) { + when (it.mode) { + UserCodeState.Mode.SHOW -> super.onBackPressed() + is UserCodeState.Mode.RESULT, + UserCodeState.Mode.SCAN -> sharedViewModel.handle(UserCodeActions.SwitchMode(UserCodeState.Mode.SHOW)) + } + } + + override fun create(initialState: UserCodeState, args: Args) = + viewModelFactory.create(initialState, args) + + companion object { + fun newIntent(context: Context, userId: String): Intent { + return Intent(context, UserCodeActivity::class.java).apply { + putExtra(MvRx.KEY_ARG, Args(userId)) + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/usercode/UserCodeShareViewEvents.kt b/vector/src/main/java/im/vector/app/features/usercode/UserCodeShareViewEvents.kt new file mode 100644 index 0000000000..26fcffadd2 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/usercode/UserCodeShareViewEvents.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2020 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.usercode + +import im.vector.app.core.platform.VectorViewEvents + +sealed class UserCodeShareViewEvents : VectorViewEvents { + data class InviteFriend(val permalink: String) : UserCodeShareViewEvents() + object Dismiss : UserCodeShareViewEvents() + object ShowWaitingScreen : UserCodeShareViewEvents() + object HideWaitingScreen : UserCodeShareViewEvents() + data class ToastMessage(val message: String) : UserCodeShareViewEvents() + data class NavigateToRoom(val roomId: String) : UserCodeShareViewEvents() +} diff --git a/vector/src/main/java/im/vector/app/features/usercode/UserCodeSharedViewModel.kt b/vector/src/main/java/im/vector/app/features/usercode/UserCodeSharedViewModel.kt new file mode 100644 index 0000000000..17dd97cffa --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/usercode/UserCodeSharedViewModel.kt @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2020 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.usercode + +import androidx.lifecycle.viewModelScope +import com.airbnb.mvrx.ActivityViewModelContext +import com.airbnb.mvrx.FragmentViewModelContext +import com.airbnb.mvrx.MvRxViewModelFactory +import com.airbnb.mvrx.ViewModelContext +import com.squareup.inject.assisted.Assisted +import com.squareup.inject.assisted.AssistedInject +import im.vector.app.R +import im.vector.app.core.platform.VectorViewModel +import im.vector.app.core.resources.StringProvider +import im.vector.app.features.raw.wellknown.getElementWellknown +import im.vector.app.features.raw.wellknown.isE2EByDefault +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.matrix.android.sdk.api.raw.RawService +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.permalinks.PermalinkData +import org.matrix.android.sdk.api.session.permalinks.PermalinkParser +import org.matrix.android.sdk.api.session.room.model.create.CreateRoomParams +import org.matrix.android.sdk.api.session.user.model.User +import org.matrix.android.sdk.api.util.toMatrixItem +import org.matrix.android.sdk.internal.util.awaitCallback + +class UserCodeSharedViewModel @AssistedInject constructor( + @Assisted val initialState: UserCodeState, + @Assisted val args: UserCodeActivity.Args, + private val session: Session, + private val stringProvider: StringProvider, + private val rawService: RawService) : VectorViewModel(initialState) { + + companion object : MvRxViewModelFactory { + override fun create(viewModelContext: ViewModelContext, state: UserCodeState): UserCodeSharedViewModel? { + val args = viewModelContext.args() + val factory = when (viewModelContext) { + is FragmentViewModelContext -> viewModelContext.fragment as? Factory + is ActivityViewModelContext -> viewModelContext.activity as? Factory + } + return factory?.create(state, args) ?: error("You should let your activity/fragment implements Factory interface") + } + + override fun initialState(viewModelContext: ViewModelContext): UserCodeState? { + return UserCodeState(viewModelContext.args().userId) + } + } + + init { + val user = session.getUser(args.userId) + setState { + copy( + matrixItem = user?.toMatrixItem(), + shareLink = session.permalinkService().createPermalink(args.userId) + ) + } + } + + private fun handleInviteFriend() { + session.permalinkService().createPermalink(initialState.userId)?.let { permalink -> + _viewEvents.post(UserCodeShareViewEvents.InviteFriend(permalink)) + } + } + + @AssistedInject.Factory + interface Factory { + fun create(initialState: UserCodeState, args: UserCodeActivity.Args): UserCodeSharedViewModel + } + + override fun handle(action: UserCodeActions) { + when (action) { + UserCodeActions.DismissAction -> _viewEvents.post(UserCodeShareViewEvents.Dismiss) + is UserCodeActions.SwitchMode -> setState { copy(mode = action.mode) } + is UserCodeActions.DecodedQRCode -> handleQrCodeDecoded(action) + is UserCodeActions.StartChattingWithUser -> handleStartChatting(action) + } + } + + private fun handleStartChatting(withUser: UserCodeActions.StartChattingWithUser) { + val mxId = withUser.matrixItem.id + val existing = session.getExistingDirectRoomWithUser(mxId) + setState { + copy(mode = UserCodeState.Mode.SHOW) + } + if (existing != null) { + // navigate to this room + _viewEvents.post(UserCodeShareViewEvents.NavigateToRoom(existing)) + } else { + // we should create the room then navigate + _viewEvents.post(UserCodeShareViewEvents.ShowWaitingScreen) + viewModelScope.launch(Dispatchers.IO) { + val adminE2EByDefault = rawService.getElementWellknown(session.myUserId) + ?.isE2EByDefault() + ?: true + + val roomParams = CreateRoomParams() + .apply { + invitedUserIds.add(mxId) + setDirectMessage() + enableEncryptionIfInvitedUsersSupportIt = adminE2EByDefault + } + + val roomId = + try { + awaitCallback { session.createRoom(roomParams, it) } + } catch (failure: Throwable) { + _viewEvents.post(UserCodeShareViewEvents.ToastMessage(stringProvider.getString(R.string.invite_users_to_room_failure))) + return@launch + } finally { + _viewEvents.post(UserCodeShareViewEvents.HideWaitingScreen) + } + _viewEvents.post(UserCodeShareViewEvents.NavigateToRoom(roomId)) + } + } + } + + private fun handleQrCodeDecoded(action: UserCodeActions.DecodedQRCode) { + val linkedId = PermalinkParser.parse(action.code) + if (linkedId is PermalinkData.FallbackLink) { + _viewEvents.post(UserCodeShareViewEvents.ToastMessage(stringProvider.getString(R.string.not_a_valid_qr_code))) + return + } + _viewEvents.post(UserCodeShareViewEvents.ShowWaitingScreen) + viewModelScope.launch(Dispatchers.IO) { + when (linkedId) { + is PermalinkData.RoomLink -> TODO() + is PermalinkData.UserLink -> { + var user = session.getUser(linkedId.userId) ?: awaitCallback> { + session.searchUsersDirectory(linkedId.userId, 10, emptySet(), it) + }.firstOrNull { it.userId == linkedId.userId } + // Create raw Uxid in case the user is not searchable + ?: User(linkedId.userId, null, null) + + setState { + copy( + mode = UserCodeState.Mode.RESULT(user.toMatrixItem()) + ) + } + } + is PermalinkData.GroupLink -> TODO() + is PermalinkData.FallbackLink -> TODO() + } + _viewEvents.post(UserCodeShareViewEvents.HideWaitingScreen) + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/usercode/UserCodeState.kt b/vector/src/main/java/im/vector/app/features/usercode/UserCodeState.kt new file mode 100644 index 0000000000..3be882af3d --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/usercode/UserCodeState.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2020 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.usercode + +import com.airbnb.mvrx.MvRxState +import org.matrix.android.sdk.api.util.MatrixItem + +data class UserCodeState( + val userId: String, + val matrixItem: MatrixItem? = null, + val shareLink: String? = null, + val mode: Mode = Mode.SHOW +) : MvRxState { + sealed class Mode { + object SHOW : Mode() + object SCAN : Mode() + data class RESULT(val matrixItem: MatrixItem) : Mode() + } +} diff --git a/vector/src/main/java/im/vector/app/features/userdirectory/ActionItem.kt b/vector/src/main/java/im/vector/app/features/userdirectory/ActionItem.kt new file mode 100644 index 0000000000..2307640634 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/userdirectory/ActionItem.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2020 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.view.View +import android.widget.ImageView +import android.widget.TextView +import androidx.annotation.DrawableRes +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import im.vector.app.R +import im.vector.app.core.epoxy.VectorEpoxyHolder +import im.vector.app.core.epoxy.VectorEpoxyModel +import im.vector.app.core.extensions.setTextOrHide + +@EpoxyModelClass(layout = R.layout.item_contact_action) +abstract class ActionItem : VectorEpoxyModel() { + + @EpoxyAttribute var title: CharSequence? = null + @EpoxyAttribute @DrawableRes var actionIconRes: Int? = null + @EpoxyAttribute var clickAction: View.OnClickListener? = null + + override fun bind(holder: Holder) { + super.bind(holder) + holder.view.setOnClickListener(clickAction) + // If name is empty, use userId as name and force it being centered + holder.actionTitleText.setTextOrHide(title) + if (actionIconRes != null) { + holder.actionTitleImageView.setImageResource(actionIconRes!!) + } else { + holder.actionTitleImageView.setImageDrawable(null) + } + } + + class Holder : VectorEpoxyHolder() { + val actionTitleText by bind(R.id.actionTitleText) + val actionTitleImageView by bind(R.id.actionIconImageView) + } +} diff --git a/vector/src/main/java/im/vector/app/features/userdirectory/ContactDetailItem.kt b/vector/src/main/java/im/vector/app/features/userdirectory/ContactDetailItem.kt new file mode 100644 index 0000000000..ee96c34f45 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/userdirectory/ContactDetailItem.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2020 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.TextView +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.core.epoxy.onClick +import im.vector.app.core.extensions.setTextOrHide + +@EpoxyModelClass(layout = R.layout.item_contact_detail) +abstract class ContactDetailItem : VectorEpoxyModel() { + + @EpoxyAttribute lateinit var threePid: String + @EpoxyAttribute var matrixId: String? = null + @EpoxyAttribute var clickListener: ClickListener? = null + + override fun bind(holder: Holder) { + super.bind(holder) + holder.view.onClick(clickListener) + holder.nameView.text = threePid + holder.matrixIdView.setTextOrHide(matrixId) + } + + class Holder : VectorEpoxyHolder() { + val nameView by bind(R.id.contactDetailName) + val matrixIdView by bind(R.id.contactDetailMatrixId) + } +} diff --git a/vector/src/main/java/im/vector/app/features/userdirectory/ContactItem.kt b/vector/src/main/java/im/vector/app/features/userdirectory/ContactItem.kt new file mode 100644 index 0000000000..d9f424d961 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/userdirectory/ContactItem.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2020 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 com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import im.vector.app.R +import im.vector.app.core.contacts.MappedContact +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_contact_main) +abstract class ContactItem : VectorEpoxyModel() { + + @EpoxyAttribute lateinit var avatarRenderer: AvatarRenderer + @EpoxyAttribute lateinit var mappedContact: MappedContact + + override fun bind(holder: Holder) { + super.bind(holder) + // If name is empty, use userId as name and force it being centered + holder.nameView.text = mappedContact.displayName + avatarRenderer.render(mappedContact, holder.avatarImageView) + } + + class Holder : VectorEpoxyHolder() { + val nameView by bind(R.id.contactDisplayName) + val avatarImageView by bind(R.id.contactAvatar) + } +} diff --git a/vector/src/main/java/im/vector/app/features/userdirectory/DirectoryUsersController.kt b/vector/src/main/java/im/vector/app/features/userdirectory/DirectoryUsersController.kt deleted file mode 100644 index e68d9855dd..0000000000 --- a/vector/src/main/java/im/vector/app/features/userdirectory/DirectoryUsersController.kt +++ /dev/null @@ -1,139 +0,0 @@ -/* - * Copyright (c) 2020 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 com.airbnb.epoxy.EpoxyController -import com.airbnb.mvrx.Fail -import com.airbnb.mvrx.Loading -import com.airbnb.mvrx.Success -import com.airbnb.mvrx.Uninitialized -import im.vector.app.R -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.StringProvider -import im.vector.app.features.home.AvatarRenderer -import org.matrix.android.sdk.api.MatrixPatterns -import org.matrix.android.sdk.api.session.Session -import org.matrix.android.sdk.api.session.user.model.User -import org.matrix.android.sdk.api.util.toMatrixItem -import javax.inject.Inject - -class DirectoryUsersController @Inject constructor(private val session: Session, - private val avatarRenderer: AvatarRenderer, - private val stringProvider: StringProvider, - private val errorFormatter: ErrorFormatter) : EpoxyController() { - - private var state: UserDirectoryViewState? = null - - var callback: Callback? = null - - init { - requestModelBuild() - } - - fun setData(state: UserDirectoryViewState) { - this.state = state - requestModelBuild() - } - - override fun buildModels() { - val currentState = state ?: return - val hasSearch = currentState.directorySearchTerm.isNotBlank() - when (val asyncUsers = currentState.directoryUsers) { - is Uninitialized -> renderEmptyState(false) - is Loading -> renderLoading() - is Success -> renderSuccess( - computeUsersList(asyncUsers(), currentState.directorySearchTerm), - currentState.getSelectedMatrixId(), - hasSearch - ) - is Fail -> renderFailure(asyncUsers.error) - } - } - - /** - * Eventually add the searched terms, if it is a userId, and if not already present in the result - */ - private fun computeUsersList(directoryUsers: List, searchTerms: String): List { - return directoryUsers + - searchTerms - .takeIf { terms -> MatrixPatterns.isUserId(terms) && !directoryUsers.any { it.userId == terms } } - ?.let { listOf(User(it)) } - .orEmpty() - } - - private fun renderLoading() { - loadingItem { - id("loading") - } - } - - private fun renderFailure(failure: Throwable) { - errorWithRetryItem { - id("error") - text(errorFormatter.toHumanReadable(failure)) - listener { callback?.retryDirectoryUsersRequest() } - } - } - - private fun renderSuccess(users: List, - selectedUsers: List, - hasSearch: Boolean) { - if (users.isEmpty()) { - renderEmptyState(hasSearch) - } else { - renderUsers(users, selectedUsers) - } - } - - private fun renderUsers(users: List, selectedUsers: List) { - for (user in users) { - if (user.userId == session.myUserId) { - continue - } - val isSelected = selectedUsers.contains(user.userId) - userDirectoryUserItem { - id(user.userId) - selected(isSelected) - matrixItem(user.toMatrixItem()) - avatarRenderer(avatarRenderer) - clickListener { _ -> - callback?.onItemClick(user) - } - } - } - } - - private fun renderEmptyState(hasSearch: Boolean) { - val noResultRes = if (hasSearch) { - R.string.no_result_placeholder - } else { - R.string.direct_room_start_search - } - noResultItem { - id("noResult") - text(stringProvider.getString(noResultRes)) - } - } - - interface Callback { - fun onItemClick(user: User) - fun retryDirectoryUsersRequest() - } -} diff --git a/vector/src/main/java/im/vector/app/features/userdirectory/KnownUsersController.kt b/vector/src/main/java/im/vector/app/features/userdirectory/KnownUsersController.kt deleted file mode 100644 index 4fbb9bbb41..0000000000 --- a/vector/src/main/java/im/vector/app/features/userdirectory/KnownUsersController.kt +++ /dev/null @@ -1,122 +0,0 @@ -/* - * Copyright (c) 2020 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 com.airbnb.epoxy.EpoxyModel -import com.airbnb.epoxy.paging.PagedListEpoxyController -import com.airbnb.mvrx.Async -import com.airbnb.mvrx.Incomplete -import com.airbnb.mvrx.Uninitialized -import im.vector.app.R -import im.vector.app.core.epoxy.EmptyItem_ -import im.vector.app.core.epoxy.loadingItem -import im.vector.app.core.epoxy.noResultItem -import im.vector.app.core.resources.StringProvider -import im.vector.app.core.utils.createUIHandler -import im.vector.app.features.home.AvatarRenderer -import org.matrix.android.sdk.api.session.Session -import org.matrix.android.sdk.api.session.user.model.User -import org.matrix.android.sdk.api.util.toMatrixItem -import javax.inject.Inject - -class KnownUsersController @Inject constructor(private val session: Session, - private val avatarRenderer: AvatarRenderer, - private val stringProvider: StringProvider) : PagedListEpoxyController( - modelBuildingHandler = createUIHandler() -) { - - private var selectedUsers: List = emptyList() - private var users: Async> = Uninitialized - private var isFiltering: Boolean = false - - var callback: Callback? = null - - init { - requestModelBuild() - } - - fun setData(state: UserDirectoryViewState) { - this.isFiltering = !state.filterKnownUsersValue.isEmpty() - val newSelection = state.getSelectedMatrixId() - this.users = state.knownUsers - if (newSelection != selectedUsers) { - this.selectedUsers = newSelection - requestForcedModelBuild() - } - submitList(state.knownUsers()) - } - - override fun buildItemModel(currentPosition: Int, item: User?): EpoxyModel<*> { - return if (item == null) { - EmptyItem_().id(currentPosition) - } else { - val isSelected = selectedUsers.contains(item.userId) - UserDirectoryUserItem_() - .id(item.userId) - .selected(isSelected) - .matrixItem(item.toMatrixItem()) - .avatarRenderer(avatarRenderer) - .clickListener { _ -> - callback?.onItemClick(item) - } - } - } - - override fun addModels(models: List>) { - if (users is Incomplete) { - renderLoading() - } else if (models.isEmpty()) { - renderEmptyState() - } else { - var lastFirstLetter: String? = null - for (model in models) { - if (model is UserDirectoryUserItem) { - if (model.matrixItem.id == session.myUserId) continue - val currentFirstLetter = model.matrixItem.firstLetterOfDisplayName() - val showLetter = !isFiltering && currentFirstLetter.isNotEmpty() && lastFirstLetter != currentFirstLetter - lastFirstLetter = currentFirstLetter - - UserDirectoryLetterHeaderItem_() - .id(currentFirstLetter) - .letter(currentFirstLetter) - .addIf(showLetter, this) - - model.addTo(this) - } else { - continue - } - } - } - } - - private fun renderLoading() { - loadingItem { - id("loading") - } - } - - private fun renderEmptyState() { - noResultItem { - id("noResult") - text(stringProvider.getString(R.string.direct_room_no_known_users)) - } - } - - interface Callback { - fun onItemClick(user: User) - } -} diff --git a/vector/src/main/java/im/vector/app/features/userdirectory/UserDirectoryFragment.kt b/vector/src/main/java/im/vector/app/features/userdirectory/UserDirectoryFragment.kt deleted file mode 100644 index 70ea9141e7..0000000000 --- a/vector/src/main/java/im/vector/app/features/userdirectory/UserDirectoryFragment.kt +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright (c) 2020 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.os.Bundle -import android.view.View -import com.airbnb.mvrx.activityViewModel -import com.airbnb.mvrx.withState -import com.jakewharton.rxbinding3.widget.textChanges -import im.vector.app.R -import im.vector.app.core.extensions.cleanup -import im.vector.app.core.extensions.configureWith -import im.vector.app.core.extensions.hideKeyboard -import im.vector.app.core.extensions.setupAsSearch -import im.vector.app.core.extensions.showKeyboard -import im.vector.app.core.platform.VectorBaseFragment -import kotlinx.android.synthetic.main.fragment_user_directory.* -import org.matrix.android.sdk.api.session.user.model.User -import javax.inject.Inject - -class UserDirectoryFragment @Inject constructor( - private val directRoomController: DirectoryUsersController -) : VectorBaseFragment(), DirectoryUsersController.Callback { - - override fun getLayoutResId() = R.layout.fragment_user_directory - private val viewModel: UserDirectoryViewModel by activityViewModel() - - private lateinit var sharedActionViewModel: UserDirectorySharedActionViewModel - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - sharedActionViewModel = activityViewModelProvider.get(UserDirectorySharedActionViewModel::class.java) - setupRecyclerView() - setupSearchByMatrixIdView() - setupCloseView() - } - - override fun onDestroyView() { - userDirectoryRecyclerView.cleanup() - directRoomController.callback = null - super.onDestroyView() - } - - private fun setupRecyclerView() { - directRoomController.callback = this - userDirectoryRecyclerView.configureWith(directRoomController) - } - - private fun setupSearchByMatrixIdView() { - userDirectorySearchById.setupAsSearch(searchIconRes = 0) - userDirectorySearchById - .textChanges() - .subscribe { - viewModel.handle(UserDirectoryAction.SearchDirectoryUsers(it.toString())) - } - .disposeOnDestroyView() - userDirectorySearchById.showKeyboard(andRequestFocus = true) - } - - private fun setupCloseView() { - userDirectoryClose.debouncedClicks { - sharedActionViewModel.post(UserDirectorySharedAction.GoBack) - } - } - - override fun invalidate() = withState(viewModel) { - directRoomController.setData(it) - } - - override fun onItemClick(user: User) { - view?.hideKeyboard() - viewModel.handle(UserDirectoryAction.SelectPendingInvitee(PendingInvitee.UserPendingInvitee(user))) - sharedActionViewModel.post(UserDirectorySharedAction.GoBack) - } - - override fun retryDirectoryUsersRequest() { - val currentSearch = userDirectorySearchById.text.toString() - viewModel.handle(UserDirectoryAction.SearchDirectoryUsers(currentSearch)) - } -} diff --git a/vector/src/main/java/im/vector/app/features/userdirectory/UserDirectoryViewModel.kt b/vector/src/main/java/im/vector/app/features/userdirectory/UserDirectoryViewModel.kt deleted file mode 100644 index 0a24b85ce2..0000000000 --- a/vector/src/main/java/im/vector/app/features/userdirectory/UserDirectoryViewModel.kt +++ /dev/null @@ -1,153 +0,0 @@ -/* - * Copyright (c) 2020 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 androidx.fragment.app.FragmentActivity -import arrow.core.Option -import com.airbnb.mvrx.ActivityViewModelContext -import com.airbnb.mvrx.FragmentViewModelContext -import com.airbnb.mvrx.MvRxViewModelFactory -import com.airbnb.mvrx.ViewModelContext -import com.jakewharton.rxrelay2.BehaviorRelay -import com.squareup.inject.assisted.Assisted -import com.squareup.inject.assisted.AssistedInject -import im.vector.app.core.extensions.exhaustive -import im.vector.app.core.extensions.toggle -import im.vector.app.core.platform.VectorViewModel -import im.vector.app.features.createdirect.CreateDirectRoomActivity -import im.vector.app.features.invite.InviteUsersToRoomActivity -import io.reactivex.Single -import io.reactivex.android.schedulers.AndroidSchedulers -import org.matrix.android.sdk.api.session.Session -import org.matrix.android.sdk.api.util.toMatrixItem -import org.matrix.android.sdk.rx.rx -import java.util.concurrent.TimeUnit - -private typealias KnowUsersFilter = String -private typealias DirectoryUsersSearch = String - -class UserDirectoryViewModel @AssistedInject constructor(@Assisted - initialState: UserDirectoryViewState, - private val session: Session) - : VectorViewModel(initialState) { - - @AssistedInject.Factory - interface Factory { - fun create(initialState: UserDirectoryViewState): UserDirectoryViewModel - } - - private val knownUsersFilter = BehaviorRelay.createDefault>(Option.empty()) - private val directoryUsersSearch = BehaviorRelay.create() - - companion object : MvRxViewModelFactory { - - override fun create(viewModelContext: ViewModelContext, state: UserDirectoryViewState): UserDirectoryViewModel? { - return when (viewModelContext) { - is FragmentViewModelContext -> (viewModelContext.fragment() as KnownUsersFragment).userDirectoryViewModelFactory.create(state) - is ActivityViewModelContext -> { - when (viewModelContext.activity()) { - is CreateDirectRoomActivity -> viewModelContext.activity().userDirectoryViewModelFactory.create(state) - is InviteUsersToRoomActivity -> viewModelContext.activity().userDirectoryViewModelFactory.create(state) - else -> error("Wrong activity or fragment") - } - } - else -> error("Wrong activity or fragment") - } - } - } - - init { - observeKnownUsers() - observeDirectoryUsers() - } - - override fun handle(action: UserDirectoryAction) { - when (action) { - is UserDirectoryAction.FilterKnownUsers -> knownUsersFilter.accept(Option.just(action.value)) - is UserDirectoryAction.ClearFilterKnownUsers -> knownUsersFilter.accept(Option.empty()) - is UserDirectoryAction.SearchDirectoryUsers -> directoryUsersSearch.accept(action.value) - is UserDirectoryAction.SelectPendingInvitee -> handleSelectUser(action) - is UserDirectoryAction.RemovePendingInvitee -> handleRemoveSelectedUser(action) - }.exhaustive - } - - private fun handleRemoveSelectedUser(action: UserDirectoryAction.RemovePendingInvitee) = withState { state -> - val selectedUsers = state.pendingInvitees.minus(action.pendingInvitee) - setState { - copy( - pendingInvitees = selectedUsers, - existingDmRoomId = getExistingDmRoomId(selectedUsers) - ) - } - } - - private fun handleSelectUser(action: UserDirectoryAction.SelectPendingInvitee) = withState { state -> - // Reset the filter asap - directoryUsersSearch.accept("") - val selectedUsers = state.pendingInvitees.toggle(action.pendingInvitee) - setState { - copy( - pendingInvitees = selectedUsers, - existingDmRoomId = getExistingDmRoomId(selectedUsers) - ) - } - } - - private fun getExistingDmRoomId(selectedUsers: Set): String? { - return selectedUsers - .takeIf { it.size == 1 } - ?.filterIsInstance(PendingInvitee.UserPendingInvitee::class.java) - ?.firstOrNull() - ?.let { invitee -> session.getExistingDirectRoomWithUser(invitee.user.userId) } - } - - private fun observeDirectoryUsers() = withState { state -> - directoryUsersSearch - .debounce(300, TimeUnit.MILLISECONDS) - .switchMapSingle { search -> - val stream = if (search.isBlank()) { - Single.just(emptyList()) - } else { - session.rx() - .searchUsersDirectory(search, 50, state.excludedUserIds ?: emptySet()) - .map { users -> - users.sortedBy { it.toMatrixItem().firstLetterOfDisplayName() } - } - } - stream.toAsync { - copy(directoryUsers = it, directorySearchTerm = search) - } - } - .subscribe() - .disposeOnClear() - } - - private fun observeKnownUsers() = withState { state -> - knownUsersFilter - .throttleLast(300, TimeUnit.MILLISECONDS) - .observeOn(AndroidSchedulers.mainThread()) - .switchMap { - session.rx().livePagedUsers(it.orNull(), state.excludedUserIds) - } - .execute { async -> - copy( - knownUsers = async, - filterKnownUsersValue = knownUsersFilter.value ?: Option.empty() - ) - } - } -} diff --git a/vector/src/main/java/im/vector/app/features/userdirectory/UserDirectoryAction.kt b/vector/src/main/java/im/vector/app/features/userdirectory/UserListAction.kt similarity index 71% rename from vector/src/main/java/im/vector/app/features/userdirectory/UserDirectoryAction.kt rename to vector/src/main/java/im/vector/app/features/userdirectory/UserListAction.kt index f4f3fb8cd4..0c2c4b1f4b 100644 --- a/vector/src/main/java/im/vector/app/features/userdirectory/UserDirectoryAction.kt +++ b/vector/src/main/java/im/vector/app/features/userdirectory/UserListAction.kt @@ -18,10 +18,10 @@ package im.vector.app.features.userdirectory import im.vector.app.core.platform.VectorViewModelAction -sealed class UserDirectoryAction : VectorViewModelAction { - data class FilterKnownUsers(val value: String) : UserDirectoryAction() - data class SearchDirectoryUsers(val value: String) : UserDirectoryAction() - object ClearFilterKnownUsers : UserDirectoryAction() - data class SelectPendingInvitee(val pendingInvitee: PendingInvitee) : UserDirectoryAction() - data class RemovePendingInvitee(val pendingInvitee: PendingInvitee) : UserDirectoryAction() +sealed class UserListAction : VectorViewModelAction { + data class SearchUsers(val value: String) : UserListAction() + object ClearSearchUsers : UserListAction() + data class SelectPendingInvitee(val pendingInvitee: PendingInvitee) : UserListAction() + data class RemovePendingInvitee(val pendingInvitee: PendingInvitee) : UserListAction() + object ComputeMatrixToLinkForSharing : 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 new file mode 100644 index 0000000000..6cd76401fd --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/userdirectory/UserListController.kt @@ -0,0 +1,197 @@ +/* + * Copyright (c) 2020 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.view.View +import com.airbnb.epoxy.EpoxyController +import com.airbnb.mvrx.Fail +import com.airbnb.mvrx.Loading +import com.airbnb.mvrx.Success +import com.airbnb.mvrx.Uninitialized +import im.vector.app.R +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.StringProvider +import im.vector.app.features.home.AvatarRenderer +import org.matrix.android.sdk.api.session.Session +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 +import javax.inject.Inject + +class UserListController @Inject constructor(private val session: Session, + private val avatarRenderer: AvatarRenderer, + private val stringProvider: StringProvider, + private val errorFormatter: ErrorFormatter) : EpoxyController() { + + private var state: UserListViewState? = null + + var callback: Callback? = null + + fun setData(state: UserListViewState) { + this.state = state + requestModelBuild() + } + + override fun buildModels() { + val currentState = state ?: return + + // Build generic items + if (currentState.searchTerm.isBlank()) { + // For now we remove this option if in invite to existing room flow (and not create DM) + if (currentState.pendingInvitees.isEmpty() + // For now we remove this option if in invite to existing room flow (and not create DM) + && currentState.existingRoomId == null) { + actionItem { + id(R.drawable.ic_invite_people) + title(stringProvider.getString(R.string.invite_friends)) + actionIconRes(R.drawable.ic_invite_people) + clickAction(View.OnClickListener { + callback?.onInviteFriendClick() + }) + } + } + actionItem { + id(R.drawable.ic_book) + title(stringProvider.getString(R.string.contacts_book_title)) + actionIconRes(R.drawable.ic_book) + clickAction(View.OnClickListener { + callback?.onContactBookClick() + }) + } + if (currentState.pendingInvitees.isEmpty() + // For now we remove this option if in invite to existing room flow (and not create DM) + && currentState.existingRoomId == null) { + actionItem { + id(R.drawable.ic_qr_code_add) + title(stringProvider.getString(R.string.qr_code)) + actionIconRes(R.drawable.ic_qr_code_add) + clickAction(View.OnClickListener { + callback?.onUseQRCode() + }) + } + } + } + + when (currentState.knownUsers) { + is Uninitialized -> renderEmptyState() + is Loading -> renderLoading() + is Fail -> renderFailure(currentState.knownUsers.error) + is Success -> buildKnownUsers(currentState, currentState.getSelectedMatrixId()) + } + + when (val asyncUsers = currentState.directoryUsers) { + is Uninitialized -> { + } + is Loading -> renderLoading() + is Fail -> renderFailure(asyncUsers.error) + is Success -> buildDirectoryUsers( + asyncUsers(), + currentState.getSelectedMatrixId(), + currentState.searchTerm, + // to avoid showing twice same user in known and suggestions + currentState.knownUsers.invoke()?.map { it.userId } ?: emptyList() + ) + } + } + + private fun buildKnownUsers(currentState: UserListViewState, selectedUsers: List) { + currentState.knownUsers()?.let { userList -> + userListHeaderItem { + id("known_header") + header(stringProvider.getString(R.string.direct_room_user_list_known_title)) + } + + if (userList.isEmpty()) { + renderEmptyState() + return + } + userList.forEach { item -> + val isSelected = selectedUsers.contains(item.userId) + userDirectoryUserItem { + id(item.userId) + selected(isSelected) + matrixItem(item.toMatrixItem()) + avatarRenderer(avatarRenderer) + clickListener { _ -> + callback?.onItemClick(item) + } + } + } + } + } + + private fun buildDirectoryUsers(directoryUsers: List, selectedUsers: List, searchTerms: String, ignoreIds: List) { + val toDisplay = directoryUsers.filter { !ignoreIds.contains(it.userId) } + if (toDisplay.isEmpty() && searchTerms.isBlank()) { + return + } + userListHeaderItem { + id("suggestions") + header(stringProvider.getString(R.string.direct_room_user_list_suggestions_title)) + } + if (toDisplay.isEmpty()) { + renderEmptyState() + } else { + toDisplay.forEach { user -> + if (user.userId != session.myUserId) { + val isSelected = selectedUsers.contains(user.userId) + userDirectoryUserItem { + id(user.userId) + selected(isSelected) + matrixItem(user.toMatrixItem()) + avatarRenderer(avatarRenderer) + clickListener { _ -> + callback?.onItemClick(user) + } + } + } + } + } + } + + private fun renderLoading() { + loadingItem { + id("loading") + } + } + + private fun renderEmptyState() { + noResultItem { + id("noResult") + text(stringProvider.getString(R.string.no_result_placeholder)) + } + } + + private fun renderFailure(failure: Throwable) { + errorWithRetryItem { + id("error") + text(errorFormatter.toHumanReadable(failure)) + } + } + + interface Callback { + fun onInviteFriendClick() + fun onContactBookClick() + fun onUseQRCode() + fun onItemClick(user: User) + fun onMatrixIdClick(matrixId: String) + fun onThreePidClick(threePid: ThreePid) + } +} diff --git a/vector/src/main/java/im/vector/app/features/userdirectory/KnownUsersFragment.kt b/vector/src/main/java/im/vector/app/features/userdirectory/UserListFragment.kt similarity index 57% rename from vector/src/main/java/im/vector/app/features/userdirectory/KnownUsersFragment.kt rename to vector/src/main/java/im/vector/app/features/userdirectory/UserListFragment.kt index ec684e8eea..4af16772b8 100644 --- a/vector/src/main/java/im/vector/app/features/userdirectory/KnownUsersFragment.kt +++ b/vector/src/main/java/im/vector/app/features/userdirectory/UserListFragment.kt @@ -36,53 +36,64 @@ import im.vector.app.core.extensions.hideKeyboard import im.vector.app.core.extensions.setupAsSearch import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.core.utils.DimensionConverter +import im.vector.app.core.utils.startSharePlainTextIntent import im.vector.app.features.homeserver.HomeServerCapabilitiesViewModel -import kotlinx.android.synthetic.main.fragment_known_users.* +import kotlinx.android.synthetic.main.fragment_user_list.* +import org.matrix.android.sdk.api.session.identity.ThreePid import org.matrix.android.sdk.api.session.user.model.User import javax.inject.Inject -class KnownUsersFragment @Inject constructor( - val userDirectoryViewModelFactory: UserDirectoryViewModel.Factory, - private val knownUsersController: KnownUsersController, +class UserListFragment @Inject constructor( + private val userListController: UserListController, private val dimensionConverter: DimensionConverter, val homeServerCapabilitiesViewModelFactory: HomeServerCapabilitiesViewModel.Factory -) : VectorBaseFragment(), KnownUsersController.Callback { +) : VectorBaseFragment(), UserListController.Callback { - private val args: KnownUsersFragmentArgs by args() + private val args: UserListFragmentArgs by args() + private val viewModel: UserListViewModel by activityViewModel() + private val homeServerCapabilitiesViewModel: HomeServerCapabilitiesViewModel by fragmentViewModel() + private lateinit var sharedActionViewModel: UserListSharedActionViewModel - override fun getLayoutResId() = R.layout.fragment_known_users + override fun getLayoutResId() = R.layout.fragment_user_list override fun getMenuRes() = args.menuResId - private val viewModel: UserDirectoryViewModel by activityViewModel() - private val homeServerCapabilitiesViewModel: HomeServerCapabilitiesViewModel by fragmentViewModel() - - private lateinit var sharedActionViewModel: UserDirectorySharedActionViewModel - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - sharedActionViewModel = activityViewModelProvider.get(UserDirectorySharedActionViewModel::class.java) + sharedActionViewModel = activityViewModelProvider.get(UserListSharedActionViewModel::class.java) + userListTitle.text = args.title + vectorBaseActivity.setSupportActionBar(userListToolbar) - knownUsersTitle.text = args.title - vectorBaseActivity.setSupportActionBar(knownUsersToolbar) setupRecyclerView() - setupFilterView() - setupAddByMatrixIdView() - setupAddFromPhoneBookView() + setupSearchView() setupCloseView() homeServerCapabilitiesViewModel.subscribe { - knownUsersE2EbyDefaultDisabled.isVisible = !it.isE2EByDefault + userListE2EbyDefaultDisabled.isVisible = !it.isE2EByDefault } - viewModel.selectSubscribe(this, UserDirectoryViewState::pendingInvitees) { + viewModel.selectSubscribe(this, UserListViewState::pendingInvitees) { renderSelectedUsers(it) } + + viewModel.observeViewEvents { + when (it) { + is UserListViewEvents.OpenShareMatrixToLing -> { + val text = getString(R.string.invite_friends_text, it.link) + startSharePlainTextIntent( + fragment = this, + activityResultLauncher = null, + chooserTitle = getString(R.string.invite_friends), + text = text, + extraTitle = getString(R.string.invite_friends_rich_title) + ) + } + } + } } override fun onDestroyView() { - knownUsersController.callback = null - knownUsersRecyclerView.cleanup() + recyclerView.cleanup() super.onDestroyView() } @@ -91,69 +102,52 @@ class KnownUsersFragment @Inject constructor( val showMenuItem = it.pendingInvitees.isNotEmpty() menu.forEach { menuItem -> menuItem.isVisible = showMenuItem - if (args.isCreatingRoom) { - menuItem.setTitle(if (it.existingDmRoomId != null) R.string.action_open else R.string.create_room_action_create) - } } } super.onPrepareOptionsMenu(menu) } override fun onOptionsItemSelected(item: MenuItem): Boolean = withState(viewModel) { - sharedActionViewModel.post(UserDirectorySharedAction.OnMenuItemSelected( - item.itemId, - it.pendingInvitees, - it.existingDmRoomId - )) + sharedActionViewModel.post(UserListSharedAction.OnMenuItemSelected(item.itemId, it.pendingInvitees)) return@withState true } - private fun setupAddByMatrixIdView() { - addByMatrixId.debouncedClicks { - sharedActionViewModel.post(UserDirectorySharedAction.OpenUsersDirectory) - } - } - - private fun setupAddFromPhoneBookView() { - addFromPhoneBook.debouncedClicks { - // TODO handle Permission first - sharedActionViewModel.post(UserDirectorySharedAction.OpenPhoneBook) - } - } - private fun setupRecyclerView() { - knownUsersController.callback = this + userListController.callback = this // Don't activate animation as we might have way to much item animation when filtering - knownUsersRecyclerView.configureWith(knownUsersController, disableItemAnimation = true) + recyclerView.configureWith(userListController, disableItemAnimation = true) } - private fun setupFilterView() { - knownUsersFilter + private fun setupSearchView() { + withState(viewModel) { + userListSearch.hint = getString(R.string.user_directory_search_hint, it.myUserId) + } + userListSearch .textChanges() - .startWith(knownUsersFilter.text) + .startWith(userListSearch.text) .subscribe { text -> - val filterValue = text.trim() - val action = if (filterValue.isBlank()) { - UserDirectoryAction.ClearFilterKnownUsers + val searchValue = text.trim() + val action = if (searchValue.isBlank()) { + UserListAction.ClearSearchUsers } else { - UserDirectoryAction.FilterKnownUsers(filterValue.toString()) + UserListAction.SearchUsers(searchValue.toString()) } viewModel.handle(action) } .disposeOnDestroyView() - knownUsersFilter.setupAsSearch() - knownUsersFilter.requestFocus() + userListSearch.setupAsSearch() + userListSearch.requestFocus() } private fun setupCloseView() { - knownUsersClose.debouncedClicks { + userListClose.debouncedClicks { requireActivity().finish() } } override fun invalidate() = withState(viewModel) { - knownUsersController.setData(it) + userListController.setData(it) } private fun renderSelectedUsers(invitees: Set) { @@ -183,12 +177,35 @@ class KnownUsersFragment @Inject constructor( chip.isCloseIconVisible = true chipGroup.addView(chip) chip.setOnCloseIconClickListener { - viewModel.handle(UserDirectoryAction.RemovePendingInvitee(pendingInvitee)) + viewModel.handle(UserListAction.RemovePendingInvitee(pendingInvitee)) } } + override fun onInviteFriendClick() { + viewModel.handle(UserListAction.ComputeMatrixToLinkForSharing) + } + + override fun onContactBookClick() { + sharedActionViewModel.post(UserListSharedAction.OpenPhoneBook) + } + override fun onItemClick(user: User) { view?.hideKeyboard() - viewModel.handle(UserDirectoryAction.SelectPendingInvitee(PendingInvitee.UserPendingInvitee(user))) + viewModel.handle(UserListAction.SelectPendingInvitee(PendingInvitee.UserPendingInvitee(user))) + } + + override fun onMatrixIdClick(matrixId: String) { + view?.hideKeyboard() + viewModel.handle(UserListAction.SelectPendingInvitee(PendingInvitee.UserPendingInvitee(User(matrixId)))) + } + + override fun onThreePidClick(threePid: ThreePid) { + view?.hideKeyboard() + viewModel.handle(UserListAction.SelectPendingInvitee(PendingInvitee.ThreePidPendingInvitee(threePid))) + } + + override fun onUseQRCode() { + view?.hideKeyboard() + sharedActionViewModel.post(UserListSharedAction.AddByQrCode) } } diff --git a/vector/src/main/java/im/vector/app/features/userdirectory/KnownUsersFragmentArgs.kt b/vector/src/main/java/im/vector/app/features/userdirectory/UserListFragmentArgs.kt similarity index 91% rename from vector/src/main/java/im/vector/app/features/userdirectory/KnownUsersFragmentArgs.kt rename to vector/src/main/java/im/vector/app/features/userdirectory/UserListFragmentArgs.kt index c20aedb803..041f29a77a 100644 --- a/vector/src/main/java/im/vector/app/features/userdirectory/KnownUsersFragmentArgs.kt +++ b/vector/src/main/java/im/vector/app/features/userdirectory/UserListFragmentArgs.kt @@ -20,9 +20,9 @@ import android.os.Parcelable import kotlinx.android.parcel.Parcelize @Parcelize -data class KnownUsersFragmentArgs( +data class UserListFragmentArgs( val title: String, val menuResId: Int, val excludedUserIds: Set? = null, - val isCreatingRoom: Boolean = false + val existingRoomId: String? = null ) : Parcelable diff --git a/vector/src/main/java/im/vector/app/features/userdirectory/UserListHeaderItem.kt b/vector/src/main/java/im/vector/app/features/userdirectory/UserListHeaderItem.kt new file mode 100644 index 0000000000..82fa4a4d6f --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/userdirectory/UserListHeaderItem.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2020 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.TextView +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import im.vector.app.R +import im.vector.app.core.epoxy.VectorEpoxyHolder +import im.vector.app.core.epoxy.VectorEpoxyModel + +@EpoxyModelClass(layout = R.layout.item_user_list_header) +abstract class UserListHeaderItem : VectorEpoxyModel() { + + @EpoxyAttribute var header: String = "" + + override fun bind(holder: Holder) { + super.bind(holder) + holder.headerTextView.text = header + } + + class Holder : VectorEpoxyHolder() { + val headerTextView by bind(R.id.userListHeaderView) + } +} diff --git a/vector/src/main/java/im/vector/app/features/userdirectory/UserDirectorySharedAction.kt b/vector/src/main/java/im/vector/app/features/userdirectory/UserListSharedAction.kt similarity index 59% rename from vector/src/main/java/im/vector/app/features/userdirectory/UserDirectorySharedAction.kt rename to vector/src/main/java/im/vector/app/features/userdirectory/UserListSharedAction.kt index 14daa67f25..b2cdee3e63 100644 --- a/vector/src/main/java/im/vector/app/features/userdirectory/UserDirectorySharedAction.kt +++ b/vector/src/main/java/im/vector/app/features/userdirectory/UserListSharedAction.kt @@ -18,12 +18,10 @@ package im.vector.app.features.userdirectory import im.vector.app.core.platform.VectorSharedAction -sealed class UserDirectorySharedAction : VectorSharedAction { - object OpenUsersDirectory : UserDirectorySharedAction() - object OpenPhoneBook : UserDirectorySharedAction() - object Close : UserDirectorySharedAction() - object GoBack : UserDirectorySharedAction() - data class OnMenuItemSelected(val itemId: Int, - val invitees: Set, - val existingDmRoomId: String?) : UserDirectorySharedAction() +sealed class UserListSharedAction : VectorSharedAction { + object Close : UserListSharedAction() + object GoBack : UserListSharedAction() + data class OnMenuItemSelected(val itemId: Int, val invitees: Set) : UserListSharedAction() + object OpenPhoneBook : UserListSharedAction() + object AddByQrCode : UserListSharedAction() } diff --git a/vector/src/main/java/im/vector/app/features/userdirectory/UserDirectorySharedActionViewModel.kt b/vector/src/main/java/im/vector/app/features/userdirectory/UserListSharedActionViewModel.kt similarity index 85% rename from vector/src/main/java/im/vector/app/features/userdirectory/UserDirectorySharedActionViewModel.kt rename to vector/src/main/java/im/vector/app/features/userdirectory/UserListSharedActionViewModel.kt index b63682e57a..05ebc73cff 100644 --- a/vector/src/main/java/im/vector/app/features/userdirectory/UserDirectorySharedActionViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/userdirectory/UserListSharedActionViewModel.kt @@ -19,4 +19,4 @@ package im.vector.app.features.userdirectory import im.vector.app.core.platform.VectorSharedActionViewModel import javax.inject.Inject -class UserDirectorySharedActionViewModel @Inject constructor() : VectorSharedActionViewModel() +class UserListSharedActionViewModel @Inject constructor() : VectorSharedActionViewModel() diff --git a/vector/src/main/java/im/vector/app/features/userdirectory/UserDirectoryViewEvents.kt b/vector/src/main/java/im/vector/app/features/userdirectory/UserListViewEvents.kt similarity index 85% rename from vector/src/main/java/im/vector/app/features/userdirectory/UserDirectoryViewEvents.kt rename to vector/src/main/java/im/vector/app/features/userdirectory/UserListViewEvents.kt index bfbdc657ef..95c6729fad 100644 --- a/vector/src/main/java/im/vector/app/features/userdirectory/UserDirectoryViewEvents.kt +++ b/vector/src/main/java/im/vector/app/features/userdirectory/UserListViewEvents.kt @@ -21,4 +21,6 @@ import im.vector.app.core.platform.VectorViewEvents /** * Transient events for invite users to room screen */ -sealed class UserDirectoryViewEvents : VectorViewEvents +sealed class UserListViewEvents : VectorViewEvents { + data class OpenShareMatrixToLing(val link: String) : UserListViewEvents() +} 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 new file mode 100644 index 0000000000..1011a3e28a --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/userdirectory/UserListViewModel.kt @@ -0,0 +1,204 @@ +/* + * Copyright (c) 2020 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 com.airbnb.mvrx.ActivityViewModelContext +import com.airbnb.mvrx.FragmentViewModelContext +import com.airbnb.mvrx.MvRxViewModelFactory +import com.airbnb.mvrx.ViewModelContext +import com.jakewharton.rxrelay2.BehaviorRelay +import com.squareup.inject.assisted.Assisted +import com.squareup.inject.assisted.AssistedInject +import im.vector.app.core.extensions.exhaustive +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 io.reactivex.disposables.Disposable +import org.matrix.android.sdk.api.MatrixPatterns +import org.matrix.android.sdk.api.session.Session +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.rx.rx +import java.util.concurrent.TimeUnit + +private typealias KnownUsersSearch = String +private typealias DirectoryUsersSearch = String + +class UserListViewModel @AssistedInject constructor(@Assisted initialState: UserListViewState, + @Assisted args: UserListFragmentArgs, + private val session: Session) + : VectorViewModel(initialState) { + + private val knownUsersSearch = BehaviorRelay.create() + private val directoryUsersSearch = BehaviorRelay.create() + + private var currentUserSearchDisposable: Disposable? = null + + @AssistedInject.Factory + interface Factory { + fun create(initialState: UserListViewState, args: UserListFragmentArgs): UserListViewModel + } + + companion object : MvRxViewModelFactory { + + private val USER_NOT_FOUND_MAP = emptyMap() + private val USER_NOT_FOUND = User("") + + override fun create(viewModelContext: ViewModelContext, state: UserListViewState): UserListViewModel? { + val factory = when (viewModelContext) { + is FragmentViewModelContext -> viewModelContext.fragment as? Factory + is ActivityViewModelContext -> viewModelContext.activity as? Factory + } + val args = viewModelContext.args() + return factory?.create(state, args) ?: error("You should let your activity/fragment implements Factory interface") + } + } + + init { + setState { + copy( + myUserId = session.myUserId, + existingRoomId = args.existingRoomId + ) + } + observeUsers() + } + + override fun handle(action: UserListAction) { + when (action) { + is UserListAction.SearchUsers -> handleSearchUsers(action.value) + is UserListAction.ClearSearchUsers -> handleClearSearchUsers() + is UserListAction.SelectPendingInvitee -> handleSelectUser(action) + is UserListAction.RemovePendingInvitee -> handleRemoveSelectedUser(action) + UserListAction.ComputeMatrixToLinkForSharing -> handleShareMyMatrixToLink() + }.exhaustive + } + + private fun handleSearchUsers(searchTerm: String) { + setState { + copy(searchTerm = searchTerm) + } + knownUsersSearch.accept(searchTerm) + directoryUsersSearch.accept(searchTerm) + } + + private fun handleShareMyMatrixToLink() { + session.permalinkService().createPermalink(session.myUserId)?.let { + _viewEvents.post(UserListViewEvents.OpenShareMatrixToLing(it)) + } + } + + private fun handleClearSearchUsers() { + knownUsersSearch.accept("") + directoryUsersSearch.accept("") + setState { + copy(searchTerm = "") + } + } + + private fun observeUsers() = withState { state -> + knownUsersSearch + .throttleLast(300, TimeUnit.MILLISECONDS) + .observeOn(AndroidSchedulers.mainThread()) + .switchMap { + session.rx().livePagedUsers(it, state.excludedUserIds) + } + .execute { async -> + copy(knownUsers = async) + } + + currentUserSearchDisposable?.dispose() + directoryUsersSearch + .debounce(300, TimeUnit.MILLISECONDS) + .switchMapSingle { search -> + val stream = if (search.isBlank()) { + Single.just(emptyList()) + } else if (MatrixPatterns.isUserId(search)) { + // If it's a valid user id try to use Profile API + // because directory only returns users that are in public rooms or share a room with you, where as + // profile will work other federations + session.rx().searchUsersDirectory(search, 50, state.excludedUserIds ?: emptySet()) + .map { users -> + users.sortedBy { it.toMatrixItem().firstLetterOfDisplayName() } + } + .zipWith( + session.rx().getProfileInfo(search) + // ... not sure how to handle that properly (manage error case in map and return optional) + .onErrorReturn { USER_NOT_FOUND_MAP } + .map { json -> + if (json === USER_NOT_FOUND_MAP) { + USER_NOT_FOUND + } else { + User( + userId = search, + displayName = json[ProfileService.DISPLAY_NAME_KEY] as? String, + avatarUrl = json[ProfileService.AVATAR_URL_KEY] as? String + ) + } + }, + { t1, t2 -> + if (t2 == USER_NOT_FOUND) { + t1 + } + // profile result might also be in search results, in this case keep search result + else if (t1.indexOfFirst { it.userId == t2.userId } != -1) { + t1 + } else { + // put it first + listOf(t2) + t1 + } + } + ) + .doOnSubscribe { + currentUserSearchDisposable = it + } + .doOnDispose { + currentUserSearchDisposable = null + } + } else { + session.rx() + .searchUsersDirectory(search, 50, state.excludedUserIds ?: emptySet()) + .map { users -> + users.sortedBy { it.toMatrixItem().firstLetterOfDisplayName() } + } + .doOnSubscribe { + currentUserSearchDisposable = it + } + .doOnDispose { + currentUserSearchDisposable = null + } + } + stream.toAsync { + copy(directoryUsers = it) + } + } + .subscribe() + .disposeOnClear() + } + + private fun handleSelectUser(action: UserListAction.SelectPendingInvitee) = withState { state -> + val selectedUsers = state.pendingInvitees.toggle(action.pendingInvitee) + setState { copy(pendingInvitees = selectedUsers) } + } + + private fun handleRemoveSelectedUser(action: UserListAction.RemovePendingInvitee) = withState { state -> + val selectedUsers = state.pendingInvitees.minus(action.pendingInvitee) + setState { copy(pendingInvitees = selectedUsers) } + } +} diff --git a/vector/src/main/java/im/vector/app/features/userdirectory/UserDirectoryViewState.kt b/vector/src/main/java/im/vector/app/features/userdirectory/UserListViewState.kt similarity index 78% rename from vector/src/main/java/im/vector/app/features/userdirectory/UserDirectoryViewState.kt rename to vector/src/main/java/im/vector/app/features/userdirectory/UserListViewState.kt index fe79a8ab37..f7cf421ca8 100644 --- a/vector/src/main/java/im/vector/app/features/userdirectory/UserDirectoryViewState.kt +++ b/vector/src/main/java/im/vector/app/features/userdirectory/UserListViewState.kt @@ -17,30 +17,29 @@ package im.vector.app.features.userdirectory import androidx.paging.PagedList -import arrow.core.Option import com.airbnb.mvrx.Async import com.airbnb.mvrx.MvRxState import com.airbnb.mvrx.Uninitialized +import im.vector.app.core.contacts.MappedContact import org.matrix.android.sdk.api.session.user.model.User -data class UserDirectoryViewState( +data class UserListViewState( val excludedUserIds: Set? = null, val knownUsers: Async> = Uninitialized, val directoryUsers: Async> = Uninitialized, + val filteredMappedContacts: List = emptyList(), val pendingInvitees: Set = emptySet(), val createAndInviteState: Async = Uninitialized, - val directorySearchTerm: String = "", - val filterKnownUsersValue: Option = Option.empty(), - val existingDmRoomId: String? = null + val searchTerm: String = "", + val myUserId: String = "", + val existingRoomId: String? = null ) : MvRxState { - constructor(args: KnownUsersFragmentArgs) : this(excludedUserIds = args.excludedUserIds) - fun getSelectedMatrixId(): List { return pendingInvitees .mapNotNull { when (it) { - is PendingInvitee.UserPendingInvitee -> it.user.userId + is PendingInvitee.UserPendingInvitee -> it.user.userId is PendingInvitee.ThreePidPendingInvitee -> null } } diff --git a/vector/src/main/res/drawable/ic_book.xml b/vector/src/main/res/drawable/ic_book.xml new file mode 100644 index 0000000000..3cd7357248 --- /dev/null +++ b/vector/src/main/res/drawable/ic_book.xml @@ -0,0 +1,21 @@ + + + + diff --git a/vector/src/main/res/drawable/ic_invite_people.xml b/vector/src/main/res/drawable/ic_invite_people.xml new file mode 100644 index 0000000000..3ec60095ff --- /dev/null +++ b/vector/src/main/res/drawable/ic_invite_people.xml @@ -0,0 +1,10 @@ + + + diff --git a/vector/src/main/res/drawable/ic_picture_icon.xml b/vector/src/main/res/drawable/ic_picture_icon.xml new file mode 100644 index 0000000000..c978a714ab --- /dev/null +++ b/vector/src/main/res/drawable/ic_picture_icon.xml @@ -0,0 +1,29 @@ + + + + + diff --git a/vector/src/main/res/drawable/ic_qr_code_add.xml b/vector/src/main/res/drawable/ic_qr_code_add.xml new file mode 100644 index 0000000000..32e41f6e57 --- /dev/null +++ b/vector/src/main/res/drawable/ic_qr_code_add.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/activity_simple.xml b/vector/src/main/res/layout/activity_simple.xml index 0eda46a67d..c6f2af8171 100644 --- a/vector/src/main/res/layout/activity_simple.xml +++ b/vector/src/main/res/layout/activity_simple.xml @@ -1,7 +1,7 @@ - @@ -10,4 +10,24 @@ android:layout_width="match_parent" android:layout_height="match_parent" /> + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/layout/fragment_home_drawer.xml b/vector/src/main/res/layout/fragment_home_drawer.xml index d34ea0b815..2f1d7cc4c4 100644 --- a/vector/src/main/res/layout/fragment_home_drawer.xml +++ b/vector/src/main/res/layout/fragment_home_drawer.xml @@ -31,10 +31,11 @@ @@ -43,13 +44,13 @@ android:id="@+id/homeDrawerUsernameView" android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_marginTop="24dp" - android:layout_marginEnd="@dimen/layout_horizontal_margin" + android:layout_marginTop="16dp" + android:layout_marginEnd="8dp" android:maxLines="1" android:singleLine="true" android:textColor="?riotx_text_primary" android:textSize="15sp" - app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintEnd_toStartOf="@+id/homeDrawerQRCodeButton" app:layout_constraintStart_toStartOf="@+id/homeDrawerHeaderAvatarView" app:layout_constraintTop_toBottomOf="@+id/homeDrawerHeaderAvatarView" tools:text="@sample/matrix.json/data/displayName" /> @@ -58,18 +59,69 @@ android:id="@+id/homeDrawerUserIdView" android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_marginEnd="@dimen/layout_horizontal_margin" - android:layout_marginBottom="17dp" + android:layout_marginEnd="8dp" android:maxLines="1" android:singleLine="true" android:textColor="?riotx_text_secondary" android:textSize="15sp" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintBottom_toTopOf="@+id/homeDrawerInviteFriendButton" + app:layout_constraintEnd_toStartOf="@+id/homeDrawerQRCodeButton" app:layout_constraintStart_toStartOf="@+id/homeDrawerHeaderAvatarView" app:layout_constraintTop_toBottomOf="@+id/homeDrawerUsernameView" tools:text="@sample/matrix.json/data/mxid" /> + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_qr_code_scanner.xml b/vector/src/main/res/layout/fragment_qr_code_scanner.xml index 589b7c73d4..135a856f4a 100644 --- a/vector/src/main/res/layout/fragment_qr_code_scanner.xml +++ b/vector/src/main/res/layout/fragment_qr_code_scanner.xml @@ -15,4 +15,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/layout/fragment_qr_code_scanner_with_button.xml b/vector/src/main/res/layout/fragment_qr_code_scanner_with_button.xml new file mode 100644 index 0000000000..6a59138990 --- /dev/null +++ b/vector/src/main/res/layout/fragment_qr_code_scanner_with_button.xml @@ -0,0 +1,51 @@ + + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/layout/fragment_room_list.xml b/vector/src/main/res/layout/fragment_room_list.xml index 855c45f7c5..72266cc21a 100644 --- a/vector/src/main/res/layout/fragment_room_list.xml +++ b/vector/src/main/res/layout/fragment_room_list.xml @@ -23,16 +23,20 @@ tools:showPaths="true" tools:visibility="visible" /> - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/layout/fragment_known_users.xml b/vector/src/main/res/layout/fragment_user_list.xml similarity index 68% rename from vector/src/main/res/layout/fragment_known_users.xml rename to vector/src/main/res/layout/fragment_user_list.xml index cf2d4e8025..15884502ad 100644 --- a/vector/src/main/res/layout/fragment_known_users.xml +++ b/vector/src/main/res/layout/fragment_user_list.xml @@ -1,5 +1,5 @@ - @@ -67,7 +67,7 @@ android:layout_marginEnd="@dimen/layout_horizontal_margin" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/knownUsersToolbar" + app:layout_constraintTop_toBottomOf="@+id/userListToolbar" app:maxHeight="64dp"> + app:layout_constraintTop_toBottomOf="@+id/userListSearch" /> - - - - - - - - + \ No newline at end of file diff --git a/vector/src/main/res/layout/item_checkbox.xml b/vector/src/main/res/layout/item_checkbox.xml new file mode 100644 index 0000000000..c7427b46c8 --- /dev/null +++ b/vector/src/main/res/layout/item_checkbox.xml @@ -0,0 +1,13 @@ + + \ No newline at end of file diff --git a/vector/src/main/res/layout/item_contact_action.xml b/vector/src/main/res/layout/item_contact_action.xml new file mode 100644 index 0000000000..daea0d5154 --- /dev/null +++ b/vector/src/main/res/layout/item_contact_action.xml @@ -0,0 +1,31 @@ + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/layout/item_user_list_header.xml b/vector/src/main/res/layout/item_user_list_header.xml new file mode 100644 index 0000000000..26591b68ca --- /dev/null +++ b/vector/src/main/res/layout/item_user_list_header.xml @@ -0,0 +1,14 @@ + + + \ No newline at end of file diff --git a/vector/src/main/res/layout/motion_dms_fab_menu_merge.xml b/vector/src/main/res/layout/motion_dms_fab_menu_merge.xml deleted file mode 100644 index c0bcec6cb3..0000000000 --- a/vector/src/main/res/layout/motion_dms_fab_menu_merge.xml +++ /dev/null @@ -1,79 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/vector/src/main/res/values/strings.xml b/vector/src/main/res/values/strings.xml index 1eb602e4c3..b6f2e15ab2 100644 --- a/vector/src/main/res/values/strings.xml +++ b/vector/src/main/res/values/strings.xml @@ -79,6 +79,7 @@ Pause Dismiss Reset + Start Chatting @@ -1754,6 +1755,7 @@ View the room directory Name or ID (#example:matrix.org) + Name or ID (like %s) Enable swipe to reply in timeline Add a dedicated tab for unread notifications on main screen. @@ -1762,10 +1764,15 @@ Add by matrix ID Add by QR code + QR code "Creating room…" "No result found, use Add by matrix ID to search on server." "Start typing to get results" "Filter by username or ID…" + Recent + Known Users + Contacts + Suggestions "Joining room…" @@ -2540,14 +2547,22 @@ INVITE Inviting users… Invite Users + Invite Friends + Hey, Talk to me on Element: %s + 🔐️ Join me on element Invitation sent to %1$s Invitations sent to %1$s and %2$s + "It's not a valid matrix QR code" Invitations sent to %1$s and one more Invitations sent to %1$s and %2$d more We could not invite users. Please check the users you want to invite and try again. + Scan + My code + This is your matrix.to code. If you share it with someone they can scan it with their element camera to add you as a contact + Current language Other available languages Loading available languages… @@ -2672,15 +2687,17 @@ Can\'t open a room where you are banned from. Can\'t find this room. Make sure it exists. + + Share by text + Cannot DM yourself! + Invalid QR code (Invalid URI)! + QR code not scanned! + The link was malformed The room is not yet created. Cancel the room creation? There are unsaved changes. Discard the changes? Discard changes - - Share by text - Cannot DM yourself! - Invalid QR code (Invalid URI)! - QR code not scanned! + Matrix Link diff --git a/vector/src/main/res/xml/motion_scene_dms_fab_menu.xml b/vector/src/main/res/xml/motion_scene_dms_fab_menu.xml deleted file mode 100644 index 8bb1c55df5..0000000000 --- a/vector/src/main/res/xml/motion_scene_dms_fab_menu.xml +++ /dev/null @@ -1,199 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file