diff --git a/changelog.d/5658.feature b/changelog.d/5658.feature new file mode 100644 index 0000000000..ba41a03207 --- /dev/null +++ b/changelog.d/5658.feature @@ -0,0 +1 @@ +Space explore screen changes: removed space card, added rooms filtering diff --git a/changelog.d/5728.misc b/changelog.d/5728.misc new file mode 100644 index 0000000000..6e463fa76f --- /dev/null +++ b/changelog.d/5728.misc @@ -0,0 +1 @@ +leaving space experience changed to be aligned with iOS diff --git a/library/core-utils/src/main/java/im/vector/lib/core/utils/flow/TimingOperators.kt b/library/core-utils/src/main/java/im/vector/lib/core/utils/flow/TimingOperators.kt index 2efb439ace..aeb5ae7914 100644 --- a/library/core-utils/src/main/java/im/vector/lib/core/utils/flow/TimingOperators.kt +++ b/library/core-utils/src/main/java/im/vector/lib/core/utils/flow/TimingOperators.kt @@ -16,6 +16,7 @@ package im.vector.lib.core.utils.flow +import android.os.SystemClock import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.Channel @@ -68,10 +69,10 @@ fun Flow.chunk(durationInMillis: Long): Flow> { @ExperimentalCoroutinesApi fun Flow.throttleFirst(windowDuration: Long): Flow = flow { - var windowStartTime = System.currentTimeMillis() + var windowStartTime = SystemClock.elapsedRealtime() var emitted = false collect { value -> - val currentTime = System.currentTimeMillis() + val currentTime = SystemClock.elapsedRealtime() val delta = currentTime - windowStartTime if (delta >= windowDuration) { windowStartTime += delta / windowDuration * windowDuration diff --git a/vector/src/androidTest/java/im/vector/app/ui/robot/space/SpaceMenuRobot.kt b/vector/src/androidTest/java/im/vector/app/ui/robot/space/SpaceMenuRobot.kt index 4d35e3c550..289c6e21b4 100644 --- a/vector/src/androidTest/java/im/vector/app/ui/robot/space/SpaceMenuRobot.kt +++ b/vector/src/androidTest/java/im/vector/app/ui/robot/space/SpaceMenuRobot.kt @@ -104,11 +104,10 @@ class SpaceMenuRobot { fun leaveSpace() { clickOnSheet(R.id.leaveSpace) - waitUntilDialogVisible(ViewMatchers.withId(R.id.leaveButton)) - clickOn(R.id.leave_selected) waitUntilActivityVisible { waitUntilViewVisible(ViewMatchers.withId(R.id.roomList)) } + clickOn(R.id.spaceLeaveSelectAll) clickOn(R.id.spaceLeaveButton) waitUntilViewVisible(ViewMatchers.withId(R.id.groupListView)) } diff --git a/vector/src/main/java/im/vector/app/core/utils/ToggleableAppBarLayoutBehavior.kt b/vector/src/main/java/im/vector/app/core/utils/ToggleableAppBarLayoutBehavior.kt new file mode 100644 index 0000000000..c829313256 --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/utils/ToggleableAppBarLayoutBehavior.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2022 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.utils + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import androidx.coordinatorlayout.widget.CoordinatorLayout +import com.google.android.material.appbar.AppBarLayout + +/** + * [AppBarLayout.Behavior] subclass with a possibility to disable behavior. + * Useful for cases when in some view state we want prevent toolbar from collapsing/expanding by scroll events + */ +class ToggleableAppBarLayoutBehavior : AppBarLayout.Behavior { + constructor() : super() + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) + + var isEnabled = true + + override fun onStartNestedScroll(parent: CoordinatorLayout, + child: AppBarLayout, + directTargetChild: View, + target: View, + nestedScrollAxes: Int, + type: Int): Boolean { + return isEnabled && super.onStartNestedScroll(parent, child, directTargetChild, target, nestedScrollAxes, type) + } + + override fun onNestedScroll(coordinatorLayout: CoordinatorLayout, + child: AppBarLayout, + target: View, + dxConsumed: Int, + dyConsumed: Int, + dxUnconsumed: Int, + dyUnconsumed: Int, + type: Int, + consumed: IntArray) { + if (!isEnabled) return + super.onNestedScroll(coordinatorLayout, child, target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, type, consumed) + } + + override fun onNestedPreScroll(coordinatorLayout: CoordinatorLayout, + child: AppBarLayout, + target: View, + dx: Int, + dy: Int, + consumed: IntArray, + type: Int) { + if (!isEnabled) return + super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type) + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/SpaceDirectoryFilterNoResults.kt b/vector/src/main/java/im/vector/app/features/home/room/list/SpaceDirectoryFilterNoResults.kt new file mode 100644 index 0000000000..1ae017c98c --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/list/SpaceDirectoryFilterNoResults.kt @@ -0,0 +1,27 @@ +/* + * 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 + +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_space_directory_filter_no_results) +abstract class SpaceDirectoryFilterNoResults : VectorEpoxyModel() { + class Holder : VectorEpoxyHolder() +} diff --git a/vector/src/main/java/im/vector/app/features/spaces/LeaveSpaceBottomSheet.kt b/vector/src/main/java/im/vector/app/features/spaces/LeaveSpaceBottomSheet.kt deleted file mode 100644 index a292b64ddd..0000000000 --- a/vector/src/main/java/im/vector/app/features/spaces/LeaveSpaceBottomSheet.kt +++ /dev/null @@ -1,194 +0,0 @@ -/* - * Copyright (c) 2021 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package im.vector.app.features.spaces - -import android.app.Activity -import android.graphics.Typeface -import android.os.Bundle -import android.os.Parcelable -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.text.toSpannable -import androidx.core.view.isInvisible -import androidx.core.view.isVisible -import androidx.lifecycle.lifecycleScope -import com.airbnb.mvrx.Fail -import com.airbnb.mvrx.Loading -import com.airbnb.mvrx.args -import com.airbnb.mvrx.parentFragmentViewModel -import com.airbnb.mvrx.withState -import dagger.hilt.android.AndroidEntryPoint -import im.vector.app.R -import im.vector.app.core.error.ErrorFormatter -import im.vector.app.core.extensions.registerStartForActivityResult -import im.vector.app.core.extensions.setTextOrHide -import im.vector.app.core.platform.VectorBaseBottomSheetDialogFragment -import im.vector.app.core.resources.ColorProvider -import im.vector.app.core.utils.styleMatchingText -import im.vector.app.databinding.BottomSheetLeaveSpaceBinding -import im.vector.app.features.displayname.getBestName -import im.vector.app.features.spaces.leave.SpaceLeaveAdvancedActivity -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.parcelize.Parcelize -import me.gujun.android.span.span -import org.matrix.android.sdk.api.util.toMatrixItem -import reactivecircus.flowbinding.android.widget.checkedChanges -import javax.inject.Inject - -@AndroidEntryPoint -class LeaveSpaceBottomSheet : VectorBaseBottomSheetDialogFragment() { - - val settingsViewModel: SpaceMenuViewModel by parentFragmentViewModel() - - override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): BottomSheetLeaveSpaceBinding { - return BottomSheetLeaveSpaceBinding.inflate(inflater, container, false) - } - - @Inject lateinit var colorProvider: ColorProvider - @Inject lateinit var errorFormatter: ErrorFormatter - - @Parcelize - data class Args( - val spaceId: String - ) : Parcelable - - override val showExpanded = true - - private val spaceArgs: SpaceBottomSheetSettingsArgs by args() - - private val cherryPickLeaveActivityResult = registerStartForActivityResult { activityResult -> - if (activityResult.resultCode == Activity.RESULT_OK) { - // nothing actually? - } else { - // move back to default - settingsViewModel.handle(SpaceLeaveViewAction.SetAutoLeaveAll) - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - views.autoLeaveRadioGroup.checkedChanges() - .onEach { - when (it) { - views.leaveAll.id -> { - settingsViewModel.handle(SpaceLeaveViewAction.SetAutoLeaveAll) - } - views.leaveNone.id -> { - settingsViewModel.handle(SpaceLeaveViewAction.SetAutoLeaveNone) - } - views.leaveSelected.id -> { - settingsViewModel.handle(SpaceLeaveViewAction.SetAutoLeaveSelected) - // launch dedicated activity - cherryPickLeaveActivityResult.launch( - SpaceLeaveAdvancedActivity.newIntent(requireContext(), spaceArgs.spaceId) - ) - } - } - } - .launchIn(viewLifecycleOwner.lifecycleScope) - - views.leaveButton.debouncedClicks { - settingsViewModel.handle(SpaceLeaveViewAction.LeaveSpace) - } - - views.cancelButton.debouncedClicks { - dismiss() - } - } - - override fun invalidate() = withState(settingsViewModel) { state -> - super.invalidate() - - val spaceSummary = state.spaceSummary ?: return@withState - val bestName = spaceSummary.toMatrixItem().getBestName() - val commonText = getString(R.string.space_leave_prompt_msg_with_name, bestName) - .toSpannable().styleMatchingText(bestName, Typeface.BOLD) - - val warningMessage: CharSequence = if (spaceSummary.otherMemberIds.isEmpty()) { - span { - +commonText - +"\n\n" - span(getString(R.string.space_leave_prompt_msg_only_you)) { - textColor = colorProvider.getColorFromAttribute(R.attr.colorError) - } - } - } else if (state.isLastAdmin) { - span { - +commonText - +"\n\n" - span(getString(R.string.space_leave_prompt_msg_as_admin)) { - textColor = colorProvider.getColorFromAttribute(R.attr.colorError) - } - } - } else if (!spaceSummary.isPublic) { - span { - +commonText - +"\n\n" - span(getString(R.string.space_leave_prompt_msg_private)) { - textColor = colorProvider.getColorFromAttribute(R.attr.colorError) - } - } - } else { - commonText - } - - views.bottomLeaveSpaceWarningText.setTextOrHide(warningMessage) - - views.inlineErrorText.setTextOrHide(null) - if (state.leavingState is Loading) { - views.leaveButton.isInvisible = true - views.cancelButton.isInvisible = true - views.leaveProgress.isVisible = true - } else { - views.leaveButton.isInvisible = false - views.cancelButton.isInvisible = false - views.leaveProgress.isVisible = false - if (state.leavingState is Fail) { - views.inlineErrorText.setTextOrHide(errorFormatter.toHumanReadable(state.leavingState.error)) - } - } - - val hasChildren = (spaceSummary.spaceChildren?.size ?: 0) > 0 - if (hasChildren) { - views.autoLeaveRadioGroup.isVisible = true - when (state.leaveMode) { - SpaceMenuState.LeaveMode.LEAVE_ALL -> { - views.autoLeaveRadioGroup.check(views.leaveAll.id) - } - SpaceMenuState.LeaveMode.LEAVE_NONE -> { - views.autoLeaveRadioGroup.check(views.leaveNone.id) - } - SpaceMenuState.LeaveMode.LEAVE_SELECTED -> { - views.autoLeaveRadioGroup.check(views.leaveSelected.id) - } - } - } else { - views.autoLeaveRadioGroup.isVisible = false - } - } - - companion object { - - fun newInstance(spaceId: String): LeaveSpaceBottomSheet { - return LeaveSpaceBottomSheet().apply { - setArguments(SpaceBottomSheetSettingsArgs(spaceId)) - } - } - } -} diff --git a/vector/src/main/java/im/vector/app/features/spaces/SpaceSettingsMenuBottomSheet.kt b/vector/src/main/java/im/vector/app/features/spaces/SpaceSettingsMenuBottomSheet.kt index 78eab5b97f..94aa7e19b8 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/SpaceSettingsMenuBottomSheet.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/SpaceSettingsMenuBottomSheet.kt @@ -35,6 +35,7 @@ import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.navigation.Navigator import im.vector.app.features.rageshake.BugReporter import im.vector.app.features.roomprofile.RoomProfileActivity +import im.vector.app.features.spaces.leave.SpaceLeaveAdvancedActivity import im.vector.app.features.spaces.manage.ManageType import im.vector.app.features.spaces.manage.SpaceManageActivity import kotlinx.parcelize.Parcelize @@ -109,7 +110,7 @@ class SpaceSettingsMenuBottomSheet : VectorBaseBottomSheetDialogFragment() { interface InteractionListener { + fun onFilterQueryChanged(query: String?) fun onButtonClick(spaceChildInfo: SpaceChildInfo) fun onSpaceChildClick(spaceChildInfo: SpaceChildInfo) fun onRoomClick(spaceChildInfo: SpaceChildInfo) @@ -62,6 +65,7 @@ class SpaceDirectoryController @Inject constructor( } var listener: InteractionListener? = null + private val matchFilter = SpaceChildInfoMatchFilter() override fun buildModels(data: SpaceDirectoryState?) { val host = this @@ -76,7 +80,7 @@ class SpaceDirectoryController @Inject constructor( val failure = results.error if (failure is Failure.ServerError && failure.error.code == M_UNRECOGNIZED) { genericPillItem { - id("HS no Support") + id("hs_no_support") imageRes(R.drawable.error) tintIcon(false) text( @@ -132,43 +136,52 @@ class SpaceDirectoryController @Inject constructor( } } } else { - flattenChildInfo.forEach { info -> - val isSpace = info.roomType == RoomType.SPACE - val isJoined = data?.joinedRoomsIds?.contains(info.childRoomId) == true - val isLoading = data?.changeMembershipStates?.get(info.childRoomId)?.isInProgress() ?: false - val error = (data?.changeMembershipStates?.get(info.childRoomId) as? ChangeMembershipState.FailedJoining)?.throwable - // if it's known use that matrixItem because it would have a better computed name - val matrixItem = data?.knownRoomSummaries?.find { it.roomId == info.childRoomId }?.toMatrixItem() - ?: info.toMatrixItem() + matchFilter.filter = data?.currentFilter ?: "" + val filteredChildInfo = flattenChildInfo.filter { matchFilter.test(it) } - spaceChildInfoItem { - id(info.childRoomId) - matrixItem(matrixItem) - avatarRenderer(host.avatarRenderer) - topic(info.topic) - suggested(info.suggested.orFalse()) - errorLabel( - error?.let { - host.stringProvider.getString(R.string.error_failed_to_join_room, host.errorFormatter.toHumanReadable(it)) + if (filteredChildInfo.isEmpty()) { + spaceDirectoryFilterNoResults { + id("no_results") + } + } else { + filteredChildInfo.forEach { info -> + val isSpace = info.roomType == RoomType.SPACE + val isJoined = data?.joinedRoomsIds?.contains(info.childRoomId) == true + val isLoading = data?.changeMembershipStates?.get(info.childRoomId)?.isInProgress() ?: false + val error = (data?.changeMembershipStates?.get(info.childRoomId) as? ChangeMembershipState.FailedJoining)?.throwable + // if it's known use that matrixItem because it would have a better computed name + val matrixItem = data?.knownRoomSummaries?.find { it.roomId == info.childRoomId }?.toMatrixItem() + ?: info.toMatrixItem() + + spaceChildInfoItem { + id(info.childRoomId) + matrixItem(matrixItem) + avatarRenderer(host.avatarRenderer) + topic(info.topic) + suggested(info.suggested.orFalse()) + errorLabel( + error?.let { + host.stringProvider.getString(R.string.error_failed_to_join_room, host.errorFormatter.toHumanReadable(it)) + } + ) + memberCount(info.activeMemberCount ?: 0) + loading(isLoading) + buttonLabel( + when { + error != null -> host.stringProvider.getString(R.string.global_retry) + isJoined -> host.stringProvider.getString(R.string.action_open) + else -> host.stringProvider.getString(R.string.action_join) + } + ) + apply { + if (isSpace) { + itemClickListener { host.listener?.onSpaceChildClick(info) } + } else { + itemClickListener { host.listener?.onRoomClick(info) } } - ) - memberCount(info.activeMemberCount ?: 0) - loading(isLoading) - buttonLabel( - when { - error != null -> host.stringProvider.getString(R.string.global_retry) - isJoined -> host.stringProvider.getString(R.string.action_open) - else -> host.stringProvider.getString(R.string.action_join) - } - ) - apply { - if (isSpace) { - itemClickListener { host.listener?.onSpaceChildClick(info) } - } else { - itemClickListener { host.listener?.onRoomClick(info) } } + buttonClickListener { host.listener?.onButtonClick(info) } } - buttonClickListener { host.listener?.onButtonClick(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 e59087778f..ed0bbdd911 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 @@ -23,6 +23,7 @@ import android.view.Menu import android.view.MenuItem import android.view.View import android.view.ViewGroup +import androidx.appcompat.widget.SearchView import androidx.core.text.toSpannable import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope @@ -44,7 +45,6 @@ import im.vector.app.core.utils.openUrlInExternalBrowser import im.vector.app.databinding.FragmentSpaceDirectoryBinding import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.home.room.detail.timeline.TimelineEventController -import im.vector.app.features.matrixto.SpaceCardRenderer import im.vector.app.features.permalink.PermalinkHandler import im.vector.app.features.spaces.manage.ManageType import im.vector.app.features.spaces.manage.SpaceAddRoomSpaceChooserBottomSheet @@ -63,7 +63,6 @@ data class SpaceDirectoryArgs( class SpaceDirectoryFragment @Inject constructor( private val epoxyController: SpaceDirectoryController, private val permalinkHandler: PermalinkHandler, - private val spaceCardRenderer: SpaceCardRenderer, private val colorProvider: ColorProvider ) : VectorBaseFragment(), SpaceDirectoryController.InteractionListener, @@ -123,9 +122,6 @@ class SpaceDirectoryFragment @Inject constructor( } } - views.spaceCard.matrixToCardMainButton.isVisible = false - views.spaceCard.matrixToCardSecondaryButton.isVisible = false - // Hide FAB when list is scrolling views.spaceDirectoryList.addOnScrollListener( object : RecyclerView.OnScrollListener() { @@ -167,18 +163,37 @@ class SpaceDirectoryFragment @Inject constructor( // it's the root toolbar?.setTitle(R.string.space_explore_activity_title) } else { - toolbar?.title = state.currentRootSummary?.name + val spaceName = state.currentRootSummary?.name ?: state.currentRootSummary?.canonicalAlias - ?: getString(R.string.space_explore_activity_title) + + if (spaceName != null) { + toolbar?.title = spaceName + toolbar?.subtitle = getString(R.string.space_explore_activity_title) + } else { + toolbar?.title = getString(R.string.space_explore_activity_title) + } } - spaceCardRenderer.render(state.currentRootSummary, emptyList(), this, views.spaceCard, showDescription = false) views.addOrCreateChatRoomButton.isVisible = state.canAddRooms } override fun onPrepareOptionsMenu(menu: Menu) = withState(viewModel) { state -> menu.findItem(R.id.spaceAddRoom)?.isVisible = state.canAddRooms menu.findItem(R.id.spaceCreateRoom)?.isVisible = false // Not yet implemented + + menu.findItem(R.id.spaceSearch)?.let { searchItem -> + val searchView = searchItem.actionView as SearchView + searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean { + return true + } + + override fun onQueryTextChange(newText: String?): Boolean { + onFilterQueryChanged(newText) + return true + } + }) + } super.onPrepareOptionsMenu(menu) } @@ -198,6 +213,10 @@ class SpaceDirectoryFragment @Inject constructor( return super.onOptionsItemSelected(item) } + override fun onFilterQueryChanged(query: String?) { + viewModel.handle(SpaceDirectoryViewAction.FilterRooms(query)) + } + override fun onButtonClick(spaceChildInfo: SpaceChildInfo) { viewModel.handle(SpaceDirectoryViewAction.JoinOrOpen(spaceChildInfo)) } diff --git a/vector/src/main/java/im/vector/app/features/spaces/explore/SpaceDirectoryViewAction.kt b/vector/src/main/java/im/vector/app/features/spaces/explore/SpaceDirectoryViewAction.kt index 2166a7e306..1d180eea4f 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/explore/SpaceDirectoryViewAction.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/explore/SpaceDirectoryViewAction.kt @@ -22,6 +22,7 @@ import org.matrix.android.sdk.api.session.room.model.SpaceChildInfo sealed class SpaceDirectoryViewAction : VectorViewModelAction { data class ExploreSubSpace(val spaceChildInfo: SpaceChildInfo) : SpaceDirectoryViewAction() data class JoinOrOpen(val spaceChildInfo: SpaceChildInfo) : SpaceDirectoryViewAction() + data class FilterRooms(val query: String?) : SpaceDirectoryViewAction() data class ShowDetails(val spaceChildInfo: SpaceChildInfo) : SpaceDirectoryViewAction() data class NavigateToRoom(val roomId: String) : SpaceDirectoryViewAction() object CreateNewRoom : SpaceDirectoryViewAction() 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 2ddcb42e2a..7ae2feebcf 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 @@ -225,9 +225,16 @@ class SpaceDirectoryViewModel @AssistedInject constructor( _viewEvents.post(SpaceDirectoryViewEvents.NavigateToCreateNewRoom(state.currentRootSummary?.roomId ?: initialState.spaceId)) } } + is SpaceDirectoryViewAction.FilterRooms -> { + filter(action.query) + } } } + private fun filter(query: String?) { + setState { copy(currentFilter = query.orEmpty()) } + } + private fun handleBack() = withState { state -> if (state.hierarchyStack.isEmpty()) { _viewEvents.post(SpaceDirectoryViewEvents.Dismiss) diff --git a/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvanceViewAction.kt b/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvanceViewAction.kt index 68b313ec7f..a25476bff9 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvanceViewAction.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvanceViewAction.kt @@ -21,6 +21,9 @@ import im.vector.app.core.platform.VectorViewModelAction sealed class SpaceLeaveAdvanceViewAction : VectorViewModelAction { data class ToggleSelection(val roomId: String) : SpaceLeaveAdvanceViewAction() data class UpdateFilter(val filter: String) : SpaceLeaveAdvanceViewAction() + data class SetFilteringEnabled(val isEnabled: Boolean) : SpaceLeaveAdvanceViewAction() object DoLeave : SpaceLeaveAdvanceViewAction() object ClearError : SpaceLeaveAdvanceViewAction() + object SelectAll : SpaceLeaveAdvanceViewAction() + object SelectNone : SpaceLeaveAdvanceViewAction() } diff --git a/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvanceViewState.kt b/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvanceViewState.kt index b8dcd3f7a2..fce5f4efa1 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvanceViewState.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvanceViewState.kt @@ -28,8 +28,11 @@ data class SpaceLeaveAdvanceViewState( val allChildren: Async> = Uninitialized, val selectedRooms: List = emptyList(), val currentFilter: String = "", - val leaveState: Async = Uninitialized + val leaveState: Async = Uninitialized, + val isFilteringEnabled: Boolean = false, + val isLastAdmin: Boolean = false ) : MavericksState { + constructor(args: SpaceBottomSheetSettingsArgs) : this( spaceId = args.spaceId ) diff --git a/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvancedFragment.kt b/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvancedFragment.kt index 53c7481acb..308572a30f 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvancedFragment.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvancedFragment.kt @@ -18,20 +18,23 @@ package im.vector.app.features.spaces.leave import android.os.Bundle import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuItem import android.view.View import android.view.ViewGroup -import androidx.lifecycle.lifecycleScope +import androidx.appcompat.widget.SearchView +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.view.isVisible +import com.airbnb.mvrx.Success import com.airbnb.mvrx.activityViewModel 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.platform.VectorBaseFragment +import im.vector.app.core.utils.ToggleableAppBarLayoutBehavior import im.vector.app.databinding.FragmentSpaceLeaveAdvancedBinding -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach import org.matrix.android.sdk.api.session.room.model.RoomSummary -import reactivecircus.flowbinding.appcompat.queryTextChanges import javax.inject.Inject class SpaceLeaveAdvancedFragment @Inject constructor( @@ -44,11 +47,33 @@ class SpaceLeaveAdvancedFragment @Inject constructor( val viewModel: SpaceLeaveAdvancedViewModel by activityViewModel() + override fun getMenuRes() = R.menu.menu_space_leave + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - setupToolbar(views.toolbar) - .allowBack() + controller.listener = this + + withState(viewModel) { state -> + setupToolbar(views.toolbar) + .setSubtitle(state.spaceSummary?.name) + .allowBack() + + state.spaceSummary?.let { summary -> + val warningMessage: CharSequence? = when { + summary.otherMemberIds.isEmpty() -> getString(R.string.space_leave_prompt_msg_only_you) + state.isLastAdmin -> getString(R.string.space_leave_prompt_msg_as_admin) + !summary.isPublic -> getString(R.string.space_leave_prompt_msg_private) + else -> null + } + + views.spaceLeavePromptDescription.isVisible = warningMessage != null + views.spaceLeavePromptDescription.text = warningMessage + } + + views.spaceLeavePromptTitle.text = getString(R.string.space_leave_prompt_msg_with_name, state.spaceSummary?.name ?: "") + } + views.roomList.configureWith(controller) views.spaceLeaveCancel.debouncedClicks { requireActivity().finish() } @@ -56,12 +81,23 @@ class SpaceLeaveAdvancedFragment @Inject constructor( viewModel.handle(SpaceLeaveAdvanceViewAction.DoLeave) } - views.publicRoomsFilter.queryTextChanges() - .debounce(100) - .onEach { - viewModel.handle(SpaceLeaveAdvanceViewAction.UpdateFilter(it.toString())) - } - .launchIn(viewLifecycleOwner.lifecycleScope) + views.spaceLeaveSelectGroup.setOnCheckedChangeListener { _, optionId -> + when (optionId) { + R.id.spaceLeaveSelectAll -> viewModel.handle(SpaceLeaveAdvanceViewAction.SelectAll) + R.id.spaceLeaveSelectNone -> viewModel.handle(SpaceLeaveAdvanceViewAction.SelectNone) + } + } + } + + override fun onPrepareOptionsMenu(menu: Menu) { + menu.findItem(R.id.menu_space_leave_search)?.let { searchItem -> + searchItem.bind( + onExpanded = { viewModel.handle(SpaceLeaveAdvanceViewAction.SetFilteringEnabled(isEnabled = true)) }, + onCollapsed = { viewModel.handle(SpaceLeaveAdvanceViewAction.SetFilteringEnabled(isEnabled = false)) }, + onTextChanged = { viewModel.handle(SpaceLeaveAdvanceViewAction.UpdateFilter(it)) } + ) + } + super.onPrepareOptionsMenu(menu) } override fun onDestroyView() { @@ -72,10 +108,63 @@ class SpaceLeaveAdvancedFragment @Inject constructor( override fun invalidate() = withState(viewModel) { state -> super.invalidate() + + if (state.isFilteringEnabled) { + views.appBarLayout.setExpanded(false) + } + + updateAppBarBehaviorState(state) + updateRadioButtonsState(state) + controller.setData(state) } override fun onItemSelected(roomSummary: RoomSummary) { viewModel.handle(SpaceLeaveAdvanceViewAction.ToggleSelection(roomSummary.roomId)) } + + private fun updateAppBarBehaviorState(state: SpaceLeaveAdvanceViewState) { + val behavior = (views.appBarLayout.layoutParams as CoordinatorLayout.LayoutParams).behavior as ToggleableAppBarLayoutBehavior + behavior.isEnabled = !state.isFilteringEnabled + } + + private fun updateRadioButtonsState(state: SpaceLeaveAdvanceViewState) { + (state.allChildren as? Success)?.invoke()?.size?.let { allChildrenCount -> + when (state.selectedRooms.size) { + 0 -> views.spaceLeaveSelectNone.isChecked = true + allChildrenCount -> views.spaceLeaveSelectAll.isChecked = true + else -> views.spaceLeaveSelectSemi.isChecked = true + } + } + } + + private fun MenuItem.bind( + onExpanded: () -> Unit, + onCollapsed: () -> Unit, + onTextChanged: (String) -> Unit) { + setOnActionExpandListener(object : MenuItem.OnActionExpandListener { + override fun onMenuItemActionExpand(item: MenuItem?): Boolean { + onExpanded() + return true + } + + override fun onMenuItemActionCollapse(item: MenuItem?): Boolean { + onCollapsed() + return true + } + }) + + val searchView = actionView as SearchView + + searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean { + return false + } + + override fun onQueryTextChange(newText: String?): Boolean { + onTextChanged(newText ?: "") + return true + } + }) + } } diff --git a/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvancedViewModel.kt b/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvancedViewModel.kt index 3f5a27f696..2ab417ac55 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvancedViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvancedViewModel.kt @@ -36,9 +36,14 @@ import okhttp3.internal.toImmutableList import org.matrix.android.sdk.api.query.ActiveSpaceFilter import org.matrix.android.sdk.api.query.RoomCategoryFilter 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.events.model.toModel import org.matrix.android.sdk.api.session.getRoom -import org.matrix.android.sdk.api.session.getRoomSummary +import org.matrix.android.sdk.api.session.room.getStateEvent import org.matrix.android.sdk.api.session.room.model.Membership +import org.matrix.android.sdk.api.session.room.model.PowerLevelsContent +import org.matrix.android.sdk.api.session.room.powerlevels.PowerLevelsHelper +import org.matrix.android.sdk.api.session.room.powerlevels.Role import org.matrix.android.sdk.api.session.room.roomSummaryQueryParams import org.matrix.android.sdk.flow.flow import org.matrix.android.sdk.flow.unwrap @@ -50,52 +55,24 @@ class SpaceLeaveAdvancedViewModel @AssistedInject constructor( private val appStateHandler: AppStateHandler ) : VectorViewModel(initialState) { - override fun handle(action: SpaceLeaveAdvanceViewAction) = withState { state -> - when (action) { - is SpaceLeaveAdvanceViewAction.ToggleSelection -> { - val existing = state.selectedRooms.toMutableList() - if (existing.contains(action.roomId)) { - existing.remove(action.roomId) - } else { - existing.add(action.roomId) - } - setState { - copy( - selectedRooms = existing.toImmutableList() - ) - } - } - is SpaceLeaveAdvanceViewAction.UpdateFilter -> { - setState { copy(currentFilter = action.filter) } - } - SpaceLeaveAdvanceViewAction.DoLeave -> { - setState { copy(leaveState = Loading()) } - viewModelScope.launch { - try { - state.selectedRooms.forEach { - try { - session.roomService().leaveRoom(it) - } catch (failure: Throwable) { - // silently ignore? - Timber.e(failure, "Fail to leave sub rooms/spaces") - } - } + init { + val space = session.getRoom(initialState.spaceId) + val spaceSummary = space?.roomSummary() - session.spaceService().leaveSpace(initialState.spaceId) - // We observe the membership and to dismiss when we have remote echo of leaving - } catch (failure: Throwable) { - setState { copy(leaveState = Fail(failure)) } - } - } - } - SpaceLeaveAdvanceViewAction.ClearError -> { - setState { copy(leaveState = Uninitialized) } + val powerLevelsEvent = space?.getStateEvent(EventType.STATE_ROOM_POWER_LEVELS) + powerLevelsEvent?.content?.toModel()?.let { powerLevelsContent -> + val powerLevelsHelper = PowerLevelsHelper(powerLevelsContent) + val isAdmin = powerLevelsHelper.getUserRole(session.myUserId) is Role.Admin + val otherAdminCount = spaceSummary?.otherMemberIds + ?.map { powerLevelsHelper.getUserRole(it) } + ?.count { it is Role.Admin } + ?: 0 + val isLastAdmin = isAdmin && otherAdminCount == 0 + setState { + copy(isLastAdmin = isLastAdmin) } } - } - init { - val spaceSummary = session.getRoomSummary(initialState.spaceId) setState { copy(spaceSummary = spaceSummary) } session.getRoom(initialState.spaceId)?.let { room -> room.flow().liveRoomSummary() @@ -127,6 +104,62 @@ class SpaceLeaveAdvancedViewModel @AssistedInject constructor( } } + override fun handle(action: SpaceLeaveAdvanceViewAction) { + when (action) { + is SpaceLeaveAdvanceViewAction.UpdateFilter -> setState { copy(currentFilter = action.filter) } + SpaceLeaveAdvanceViewAction.ClearError -> setState { copy(leaveState = Uninitialized) } + SpaceLeaveAdvanceViewAction.SelectNone -> setState { copy(selectedRooms = emptyList()) } + is SpaceLeaveAdvanceViewAction.SetFilteringEnabled -> setState { copy(isFilteringEnabled = action.isEnabled) } + is SpaceLeaveAdvanceViewAction.ToggleSelection -> handleSelectionToggle(action) + SpaceLeaveAdvanceViewAction.DoLeave -> handleLeave() + SpaceLeaveAdvanceViewAction.SelectAll -> handleSelectAll() + } + } + + private fun handleSelectAll() = withState { state -> + val filteredRooms = (state.allChildren as? Success)?.invoke()?.filter { + it.name.contains(state.currentFilter, true) + } + filteredRooms?.let { + setState { copy(selectedRooms = it.map { it.roomId }) } + } + } + + private fun handleLeave() = withState { state -> + setState { copy(leaveState = Loading()) } + viewModelScope.launch { + try { + state.selectedRooms.forEach { + try { + session.roomService().leaveRoom(it) + } catch (failure: Throwable) { + // silently ignore? + Timber.e(failure, "Fail to leave sub rooms/spaces") + } + } + + session.spaceService().leaveSpace(initialState.spaceId) + // We observe the membership and to dismiss when we have remote echo of leaving + } catch (failure: Throwable) { + setState { copy(leaveState = Fail(failure)) } + } + } + } + + private fun handleSelectionToggle(action: SpaceLeaveAdvanceViewAction.ToggleSelection) = withState { state -> + val existing = state.selectedRooms.toMutableList() + if (existing.contains(action.roomId)) { + existing.remove(action.roomId) + } else { + existing.add(action.roomId) + } + setState { + copy( + selectedRooms = existing.toImmutableList(), + ) + } + } + @AssistedFactory interface Factory : MavericksAssistedViewModelFactory { override fun create(initialState: SpaceLeaveAdvanceViewState): SpaceLeaveAdvancedViewModel diff --git a/vector/src/main/res/layout/bottom_sheet_leave_space.xml b/vector/src/main/res/layout/bottom_sheet_leave_space.xml deleted file mode 100644 index 9e5a7c7ebf..0000000000 --- a/vector/src/main/res/layout/bottom_sheet_leave_space.xml +++ /dev/null @@ -1,105 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - -