diff --git a/vector/src/main/java/im/vector/app/core/ui/list/GenericEmptyWithActionItem.kt b/vector/src/main/java/im/vector/app/core/ui/list/GenericEmptyWithActionItem.kt new file mode 100644 index 0000000000..f8eb968268 --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/ui/list/GenericEmptyWithActionItem.kt @@ -0,0 +1,92 @@ +/* + * 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.core.ui.list + +import android.content.res.ColorStateList +import android.view.View +import android.widget.Button +import android.widget.ImageView +import android.widget.TextView +import androidx.annotation.ColorInt +import androidx.annotation.DrawableRes +import androidx.core.view.isVisible +import androidx.core.widget.ImageViewCompat +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 + +/** + * A generic list item to display when there is no results, with an optional CTA + */ +@EpoxyModelClass(layout = R.layout.item_generic_empty_state) +abstract class GenericEmptyWithActionItem : VectorEpoxyModel<GenericEmptyWithActionItem.Holder>() { + + class Action(var title: String) { + var perform: Runnable? = null + } + + @EpoxyAttribute + var title: CharSequence? = null + + @EpoxyAttribute + var description: CharSequence? = null + + @EpoxyAttribute + @DrawableRes + var iconRes: Int = -1 + + @EpoxyAttribute + @ColorInt + var iconTint: Int? = null + + @EpoxyAttribute + var buttonAction: Action? = null + + override fun bind(holder: Holder) { + super.bind(holder) + + holder.titleText.setTextOrHide(title) + holder.descriptionText.setTextOrHide(description) + + if (iconRes != -1) { + holder.imageView.setImageResource(iconRes) + holder.imageView.isVisible = true + if (iconTint != null) { + ImageViewCompat.setImageTintList(holder.imageView, ColorStateList.valueOf(iconTint!!)) + } else { + ImageViewCompat.setImageTintList(holder.imageView, null) + } + } else { + holder.imageView.isVisible = false + } + + holder.actionButton.setTextOrHide(buttonAction?.title) + holder.actionButton.setOnClickListener { + buttonAction?.perform?.run() + } + } + + class Holder : VectorEpoxyHolder() { + val root by bind<View>(R.id.item_generic_root) + val titleText by bind<TextView>(R.id.emptyItemTitleView) + val descriptionText by bind<TextView>(R.id.emptyItemMessageView) + val imageView by bind<ImageView>(R.id.emptyItemImageView) + val actionButton by bind<Button>(R.id.emptyItemButton) + } +} diff --git a/vector/src/main/java/im/vector/app/features/spaces/explore/SpaceDirectoryController.kt b/vector/src/main/java/im/vector/app/features/spaces/explore/SpaceDirectoryController.kt index 734c4e3261..c7ec61aaa1 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/explore/SpaceDirectoryController.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/explore/SpaceDirectoryController.kt @@ -26,7 +26,8 @@ import im.vector.app.core.epoxy.loadingItem import im.vector.app.core.error.ErrorFormatter import im.vector.app.core.resources.ColorProvider import im.vector.app.core.resources.StringProvider -import im.vector.app.core.ui.list.genericFooterItem +import im.vector.app.core.ui.list.GenericEmptyWithActionItem +import im.vector.app.core.ui.list.genericEmptyWithActionItem import im.vector.app.core.ui.list.genericPillItem import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.room.list.spaceChildInfoItem @@ -50,6 +51,7 @@ class SpaceDirectoryController @Inject constructor( fun onSpaceChildClick(spaceChildInfo: SpaceChildInfo) fun onRoomClick(spaceChildInfo: SpaceChildInfo) fun retry() + fun addExistingRooms(spaceId: String) } var listener: InteractionListener? = null @@ -97,9 +99,23 @@ class SpaceDirectoryController @Inject constructor( ?: emptyList() if (flattenChildInfo.isEmpty()) { - genericFooterItem { - id("empty_footer") - host.stringProvider.getString(R.string.no_result_placeholder) + genericEmptyWithActionItem { + id("empty_res") + title(host.stringProvider.getString(R.string.this_space_has_no_rooms)) + iconRes(R.drawable.ic_empty_icon_room) + iconTint(host.colorProvider.getColorFromAttribute(R.attr.riotx_reaction_background_on)) + apply { + if (data?.canAddRooms == true) { + description(host.stringProvider.getString(R.string.this_space_has_no_rooms_admin)) + val action = GenericEmptyWithActionItem.Action(host.stringProvider.getString(R.string.space_add_existing_rooms)) + action.perform = Runnable { + host.listener?.addExistingRooms(data.spaceId) + } + buttonAction(action) + } else { + description(host.stringProvider.getString(R.string.this_space_has_no_rooms_not_admin)) + } + } } } else { flattenChildInfo.forEach { info -> diff --git a/vector/src/main/java/im/vector/app/features/spaces/explore/SpaceDirectoryFragment.kt b/vector/src/main/java/im/vector/app/features/spaces/explore/SpaceDirectoryFragment.kt index fa44f4595e..2402e95e76 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/explore/SpaceDirectoryFragment.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/explore/SpaceDirectoryFragment.kt @@ -19,6 +19,8 @@ package im.vector.app.features.spaces.explore import android.os.Bundle import android.os.Parcelable import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuItem import android.view.View import android.view.ViewGroup import com.airbnb.mvrx.activityViewModel @@ -26,9 +28,12 @@ import com.airbnb.mvrx.withState 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.registerStartForActivityResult import im.vector.app.core.platform.OnBackPressed import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.databinding.FragmentRoomDirectoryPickerBinding +import im.vector.app.features.spaces.manage.ManageType +import im.vector.app.features.spaces.manage.SpaceManageActivity import kotlinx.parcelize.Parcelize import org.matrix.android.sdk.api.session.room.model.SpaceChildInfo import javax.inject.Inject @@ -44,6 +49,8 @@ class SpaceDirectoryFragment @Inject constructor( SpaceDirectoryController.InteractionListener, OnBackPressed { + override fun getMenuRes() = R.menu.menu_space_directory + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?) = FragmentRoomDirectoryPickerBinding.inflate(layoutInflater, container, false) @@ -60,6 +67,10 @@ class SpaceDirectoryFragment @Inject constructor( } epoxyController.listener = this views.roomDirectoryPickerList.configureWith(epoxyController) + + viewModel.selectSubscribe(this, SpaceDirectoryState::canAddRooms) { + invalidateOptionsMenu() + } } override fun onDestroyView() { @@ -77,6 +88,32 @@ class SpaceDirectoryFragment @Inject constructor( views.toolbar.title = title } + override fun onPrepareOptionsMenu(menu: Menu) = withState(viewModel) { state -> + menu.findItem(R.id.spaceAddRoom)?.let { + it.isVisible = state.canAddRooms + } + menu.findItem(R.id.spaceCreateRoom)?.let { + it.isVisible = false // Not yet implemented + } + super.onPrepareOptionsMenu(menu) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.spaceAddRoom -> { + withState(viewModel) { state -> + addExistingRooms(state.spaceId) + } + return true + } + R.id.spaceCreateRoom -> { + // not implemented yet + return true + } + } + return super.onOptionsItemSelected(item) + } + override fun onButtonClick(spaceChildInfo: SpaceChildInfo) { viewModel.handle(SpaceDirectoryViewAction.JoinOrOpen(spaceChildInfo)) } @@ -97,6 +134,14 @@ class SpaceDirectoryFragment @Inject constructor( override fun retry() { viewModel.handle(SpaceDirectoryViewAction.Retry) } + + private val addExistingRoomActivityResult = registerStartForActivityResult { activityResult -> + viewModel.handle(SpaceDirectoryViewAction.Retry) + } + + override fun addExistingRooms(spaceId: String) { + addExistingRoomActivityResult.launch(SpaceManageActivity.newIntent(requireContext(), spaceId, ManageType.AddRooms)) + } // override fun navigateToRoom(roomId: String) { // viewModel.handle(SpaceDirectoryViewAction.NavigateToRoom(roomId)) // } diff --git a/vector/src/main/java/im/vector/app/features/spaces/explore/SpaceDirectoryState.kt b/vector/src/main/java/im/vector/app/features/spaces/explore/SpaceDirectoryState.kt index 75adc659d5..220c3e3492 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/explore/SpaceDirectoryState.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/explore/SpaceDirectoryState.kt @@ -36,7 +36,8 @@ data class SpaceDirectoryState( // Set of joined roomId / spaces, val joinedRoomsIds: Set<String> = emptySet(), // keys are room alias or roomId - val changeMembershipStates: Map<String, ChangeMembershipState> = emptyMap() + val changeMembershipStates: Map<String, ChangeMembershipState> = emptyMap(), + val canAddRooms: Boolean = false ) : MvRxState { constructor(args: SpaceDirectoryArgs) : this( spaceId = args.spaceId diff --git a/vector/src/main/java/im/vector/app/features/spaces/explore/SpaceDirectoryViewModel.kt b/vector/src/main/java/im/vector/app/features/spaces/explore/SpaceDirectoryViewModel.kt index 0c23752936..313ddfe1dc 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/explore/SpaceDirectoryViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/explore/SpaceDirectoryViewModel.kt @@ -28,12 +28,15 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import im.vector.app.core.platform.VectorViewModel +import im.vector.app.features.powerlevel.PowerLevelsObservableFactory import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.api.session.room.model.RoomType import org.matrix.android.sdk.api.session.room.model.SpaceChildInfo +import org.matrix.android.sdk.api.session.room.powerlevels.PowerLevelsHelper import org.matrix.android.sdk.api.session.room.roomSummaryQueryParams import org.matrix.android.sdk.rx.rx import timber.log.Timber @@ -70,6 +73,23 @@ class SpaceDirectoryViewModel @AssistedInject constructor( refreshFromApi() observeJoinedRooms() observeMembershipChanges() + observePermissions() + } + + private fun observePermissions() { + val room = session.getRoom(initialState.spaceId) ?: return + + val powerLevelsContentLive = PowerLevelsObservableFactory(room).createObservable() + + powerLevelsContentLive + .subscribe { + val powerLevelsHelper = PowerLevelsHelper(it) + setState { + copy(canAddRooms = powerLevelsHelper.isUserAllowedToSend(session.myUserId, true, + EventType.STATE_SPACE_CHILD)) + } + } + .disposeOnClear() } private fun refreshFromApi() { diff --git a/vector/src/main/java/im/vector/app/features/spaces/manage/SpaceManageRoomsController.kt b/vector/src/main/java/im/vector/app/features/spaces/manage/SpaceManageRoomsController.kt index 5e3d6ab6d0..b16c6de921 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/manage/SpaceManageRoomsController.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/manage/SpaceManageRoomsController.kt @@ -19,9 +19,12 @@ package im.vector.app.features.spaces.manage import com.airbnb.epoxy.TypedEpoxyController import com.airbnb.mvrx.Fail import com.airbnb.mvrx.Incomplete +import im.vector.app.R import im.vector.app.core.epoxy.errorWithRetryItem import im.vector.app.core.epoxy.loadingItem import im.vector.app.core.error.ErrorFormatter +import im.vector.app.core.resources.StringProvider +import im.vector.app.core.ui.list.genericFooterItem import im.vector.app.core.utils.DebouncedClickListener import im.vector.app.features.home.AvatarRenderer import org.matrix.android.sdk.api.session.room.model.RoomType @@ -31,7 +34,8 @@ import javax.inject.Inject class SpaceManageRoomsController @Inject constructor( private val avatarRenderer: AvatarRenderer, - private val errorFormatter: ErrorFormatter + private val errorFormatter: ErrorFormatter, + private val stringProvider: StringProvider ) : TypedEpoxyController<SpaceManageRoomViewState>() { interface Listener { @@ -67,17 +71,24 @@ class SpaceManageRoomsController @Inject constructor( matchFilter.filter = data.currentFilter val filteredResult = directChildren.filter { matchFilter.test(it) } - filteredResult.forEach { childInfo -> - roomManageSelectionItem { - id(childInfo.childRoomId) - matrixItem(childInfo.toMatrixItem()) - avatarRenderer(host.avatarRenderer) - suggested(childInfo.suggested ?: false) - space(childInfo.roomType == RoomType.SPACE) - selected(data.selectedRooms.contains(childInfo.childRoomId)) - itemClickListener(DebouncedClickListener({ - host.listener?.toggleSelection(childInfo) - })) + if (filteredResult.isEmpty()) { + genericFooterItem { + id("empty_result") + text(host.stringProvider.getString(R.string.no_result_placeholder)) + } + } else { + filteredResult.forEach { childInfo -> + roomManageSelectionItem { + id(childInfo.childRoomId) + matrixItem(childInfo.toMatrixItem()) + avatarRenderer(host.avatarRenderer) + suggested(childInfo.suggested ?: false) + space(childInfo.roomType == RoomType.SPACE) + selected(data.selectedRooms.contains(childInfo.childRoomId)) + itemClickListener(DebouncedClickListener({ + host.listener?.toggleSelection(childInfo) + })) + } } } } diff --git a/vector/src/main/res/drawable/ic_empty_icon_room.xml b/vector/src/main/res/drawable/ic_empty_icon_room.xml new file mode 100644 index 0000000000..07fbb64749 --- /dev/null +++ b/vector/src/main/res/drawable/ic_empty_icon_room.xml @@ -0,0 +1,13 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="48dp" + android:height="48dp" + android:viewportWidth="48" + android:viewportHeight="48"> + <path + android:pathData="M21.5187,26.2723H25.8404L26.3357,21.6964H22.014L21.5187,26.2723Z" + android:fillColor="#C1C6CD"/> + <path + android:pathData="M44,24C44,35.0457 35.0457,44 24,44C12.9543,44 4,35.0457 4,24C4,12.9543 12.9543,4 24,4C35.0457,4 44,12.9543 44,24ZM21.0505,12.0116C22.1487,12.1305 22.9425,13.1171 22.8237,14.2152L22.4469,17.6964H26.7686L27.192,13.7848C27.3109,12.6866 28.2974,11.8928 29.3956,12.0116C30.4938,12.1305 31.2876,13.1171 31.1688,14.2152L30.792,17.6964H32.6C33.7046,17.6964 34.6,18.5918 34.6,19.6964C34.6,20.801 33.7046,21.6964 32.6,21.6964H30.3591L29.8638,26.2723H32.6C33.7046,26.2723 34.6,27.1677 34.6,28.2723C34.6,29.3769 33.7046,30.2723 32.6,30.2723H29.4308L29.0041,34.2152C28.8852,35.3134 27.8986,36.1072 26.8005,35.9884C25.7023,35.8695 24.9084,34.8829 25.0273,33.7848L25.4075,30.2723H21.0857L20.659,34.2152C20.5401,35.3134 19.5535,36.1072 18.4554,35.9884C17.3572,35.8695 16.5633,34.8829 16.6822,33.7848L17.0624,30.2723H15C13.8954,30.2723 13,29.3769 13,28.2723C13,27.1677 13.8954,26.2723 15,26.2723H17.4953L17.9906,21.6964H15.8784C14.7739,21.6964 13.8784,20.801 13.8784,19.6964C13.8784,18.5918 14.7739,17.6964 15.8784,17.6964H18.4235L18.8469,13.7848C18.9658,12.6866 19.9524,11.8928 21.0505,12.0116Z" + android:fillColor="#C1C6CD" + android:fillType="evenOdd"/> +</vector> diff --git a/vector/src/main/res/layout/fragment_room_directory_picker.xml b/vector/src/main/res/layout/fragment_room_directory_picker.xml index f0b7a22b8c..eb04084e9a 100644 --- a/vector/src/main/res/layout/fragment_room_directory_picker.xml +++ b/vector/src/main/res/layout/fragment_room_directory_picker.xml @@ -5,7 +5,7 @@ android:id="@+id/coordinatorLayout" android:layout_width="match_parent" android:layout_height="match_parent" - android:background="?riotx_header_panel_background"> + android:background="?riotx_background"> <androidx.constraintlayout.widget.ConstraintLayout android:layout_width="match_parent" diff --git a/vector/src/main/res/layout/item_generic_empty_state.xml b/vector/src/main/res/layout/item_generic_empty_state.xml new file mode 100644 index 0000000000..8b72f4e2e9 --- /dev/null +++ b/vector/src/main/res/layout/item_generic_empty_state.xml @@ -0,0 +1,70 @@ +<?xml version="1.0" encoding="utf-8"?> +<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:padding="16dp"> + + <ImageView + android:id="@+id/emptyItemImageView" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="20dp" + android:layout_marginBottom="16dp" + android:tint="?riotx_reaction_background_off" + app:layout_constraintBottom_toTopOf="@id/emptyItemTitleView" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + tools:src="@drawable/ic_empty_space_explore" /> + + <TextView + android:id="@+id/emptyItemTitleView" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center_horizontal" + android:layout_marginTop="30dp" + android:gravity="center" + android:textColor="?riotx_text_primary" + android:textSize="15sp" + android:textStyle="bold" + app:layout_constraintBottom_toTopOf="@id/emptyItemMessageView" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/emptyItemImageView" + app:layout_constraintVertical_chainStyle="packed" + tools:text="@string/this_space_has_no_rooms" /> + + <TextView + android:id="@+id/emptyItemMessageView" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:layout_marginTop="20dp" + android:ellipsize="end" + android:gravity="center" + android:maxWidth="300dp" + android:maxLines="10" + android:textColor="?riotx_text_secondary" + android:textSize="14sp" + app:layout_constraintBottom_toTopOf="@+id/emptyItemButton" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/emptyItemTitleView" + tools:text="@string/this_space_has_no_rooms_admin" /> + + <com.google.android.material.button.MaterialButton + android:id="@+id/emptyItemButton" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:layout_marginTop="16dp" + android:minWidth="190dp" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/emptyItemMessageView" + tools:text="@string/space_add_existing_rooms" /> + +</androidx.constraintlayout.widget.ConstraintLayout> \ No newline at end of file diff --git a/vector/src/main/res/menu/menu_space_directory.xml b/vector/src/main/res/menu/menu_space_directory.xml new file mode 100644 index 0000000000..23fe7991c7 --- /dev/null +++ b/vector/src/main/res/menu/menu_space_directory.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="utf-8"?> +<menu xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto"> + <item + android:id="@+id/spaceAddRoom" + android:title="@string/space_add_existing_rooms" + app:showAsAction="never" /> + + <item + android:id="@+id/spaceCreateRoom" + android:title="@string/create_new_room" + app:iconTint="?attr/colorAccent" + app:showAsAction="never" /> + +</menu> \ No newline at end of file diff --git a/vector/src/main/res/values/strings.xml b/vector/src/main/res/values/strings.xml index 94273a62c7..56e27dce86 100644 --- a/vector/src/main/res/values/strings.xml +++ b/vector/src/main/res/values/strings.xml @@ -3356,6 +3356,7 @@ <string name="space_add_existing_rooms">Add existing rooms and space</string> + <string name="space_add_rooms">Add rooms</string> <string name="spaces_beta_welcome_to_spaces">Welcome to Spaces!</string> <string name="spaces_beta_welcome_to_spaces_desc">Spaces are a new way to group rooms and people.</string> <string name="you_are_invited">You are invited</string> @@ -3377,5 +3378,10 @@ <string name="labs_space_show_orphan_in_home">Experimental Space - Only show orphans in Home</string> <string name="spaces_feeling_experimental_subspace">Feeling experimental?\nYou can add existing spaces to a space.</string> <string name="spaces_no_server_support_title">It looks like your homeserver does not support Spaces yet</string> - <string name="spaces_no_server_support_description">Please contact your homserver admin for further information</string> + <string name="spaces_no_server_support_description">Please contact your homeserver admin for further information</string> + + <string name="this_space_has_no_rooms">This space has no rooms</string> + <string name="this_space_has_no_rooms_not_admin">Some rooms may be hidden because they’re private and you need an invite.\nYou don’t have permission to add rooms.</string> + <string name="this_space_has_no_rooms_admin">Some rooms may be hidden because they’re private and you need an invite.</string> + </resources>