Room list contextual menu (#427)
- Adds `ModalBottomSheet` to our design components (it wraps the homonimous Material3 one). - Adds a bottom sheet to the Room list using the aforementioned design component. - Adds navigation from the room list to a room detail (context menu "Settings" action). - Consolidates the "leave room flow" into a new `leaveroom` module used by both the room list and the room details. - Adds progress indicator to the leave room flow - Uses new `leaveroom` module in `roomdetails` module too. Parent issue: - https://github.com/vector-im/element-x-android/issues/261
This commit is contained in:
parent
a1038cd49c
commit
67a8802766
60 changed files with 1462 additions and 250 deletions
|
|
@ -135,7 +135,10 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||||
object RoomList : NavTarget
|
object RoomList : NavTarget
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class Room(val roomId: RoomId) : NavTarget
|
data class Room(
|
||||||
|
val roomId: RoomId,
|
||||||
|
val initialElement: RoomFlowNode.NavTarget = RoomFlowNode.NavTarget.Messages
|
||||||
|
) : NavTarget
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
object Settings : NavTarget
|
object Settings : NavTarget
|
||||||
|
|
@ -176,6 +179,10 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||||
override fun onInvitesClicked() {
|
override fun onInvitesClicked() {
|
||||||
backstack.push(NavTarget.InviteList)
|
backstack.push(NavTarget.InviteList)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onRoomSettingsClicked(roomId: RoomId) {
|
||||||
|
backstack.push(NavTarget.Room(roomId, initialElement = RoomFlowNode.NavTarget.RoomDetails))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
roomListEntryPoint
|
roomListEntryPoint
|
||||||
.nodeBuilder(this, buildContext)
|
.nodeBuilder(this, buildContext)
|
||||||
|
|
@ -193,7 +200,7 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val nodeLifecycleCallbacks = plugins<NodeLifecycleCallback>()
|
val nodeLifecycleCallbacks = plugins<NodeLifecycleCallback>()
|
||||||
val inputs = RoomFlowNode.Inputs(room)
|
val inputs = RoomFlowNode.Inputs(room, initialElement = navTarget.initialElement)
|
||||||
createNode<RoomFlowNode>(buildContext, plugins = listOf(inputs) + nodeLifecycleCallbacks)
|
createNode<RoomFlowNode>(buildContext, plugins = listOf(inputs) + nodeLifecycleCallbacks)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ class RoomFlowNode @AssistedInject constructor(
|
||||||
roomMembershipObserver: RoomMembershipObserver,
|
roomMembershipObserver: RoomMembershipObserver,
|
||||||
) : BackstackNode<RoomFlowNode.NavTarget>(
|
) : BackstackNode<RoomFlowNode.NavTarget>(
|
||||||
backstack = BackStack(
|
backstack = BackStack(
|
||||||
initialElement = NavTarget.Messages,
|
initialElement = plugins.filterIsInstance(Inputs::class.java).first().initialElement,
|
||||||
savedStateMap = buildContext.savedStateMap,
|
savedStateMap = buildContext.savedStateMap,
|
||||||
),
|
),
|
||||||
buildContext = buildContext,
|
buildContext = buildContext,
|
||||||
|
|
@ -73,6 +73,7 @@ class RoomFlowNode @AssistedInject constructor(
|
||||||
|
|
||||||
data class Inputs(
|
data class Inputs(
|
||||||
val room: MatrixRoom,
|
val room: MatrixRoom,
|
||||||
|
val initialElement: NavTarget = NavTarget.Messages,
|
||||||
) : NodeInputs
|
) : NodeInputs
|
||||||
|
|
||||||
private val inputs: Inputs = inputs()
|
private val inputs: Inputs = inputs()
|
||||||
|
|
@ -98,6 +99,7 @@ class RoomFlowNode @AssistedInject constructor(
|
||||||
navigateUp()
|
navigateUp()
|
||||||
}
|
}
|
||||||
.launchIn(lifecycleScope)
|
.launchIn(lifecycleScope)
|
||||||
|
inputs<Inputs>()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun fetchRoomMembers() = lifecycleScope.launch {
|
private fun fetchRoomMembers() = lifecycleScope.launch {
|
||||||
|
|
|
||||||
1
changelog.d/427.feature
Normal file
1
changelog.d/427.feature
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Room list contextual menu
|
||||||
31
features/leaveroom/api/build.gradle.kts
Normal file
31
features/leaveroom/api/build.gradle.kts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 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.
|
||||||
|
*/
|
||||||
|
plugins {
|
||||||
|
id("io.element.android-compose-library")
|
||||||
|
alias(libs.plugins.ksp)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "io.element.android.features.leaveroom.api"
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(projects.libraries.architecture)
|
||||||
|
implementation(projects.libraries.designsystem)
|
||||||
|
implementation(projects.libraries.uiStrings)
|
||||||
|
implementation(projects.libraries.matrix.api)
|
||||||
|
ksp(libs.showkase.processor)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 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 io.element.android.features.leaveroom.api
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
|
||||||
|
sealed interface LeaveRoomEvent {
|
||||||
|
data class ShowConfirmation(val roomId: RoomId) : LeaveRoomEvent
|
||||||
|
object HideConfirmation : LeaveRoomEvent
|
||||||
|
data class LeaveRoom(val roomId: RoomId) : LeaveRoomEvent
|
||||||
|
object HideError : LeaveRoomEvent
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 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 io.element.android.features.leaveroom.api
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
|
||||||
|
interface LeaveRoomPresenter : Presenter<LeaveRoomState> {
|
||||||
|
@Composable
|
||||||
|
override fun present(): LeaveRoomState
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 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 io.element.android.features.leaveroom.api
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
|
||||||
|
data class LeaveRoomState(
|
||||||
|
val confirmation: Confirmation = Confirmation.Hidden,
|
||||||
|
val progress: Progress = Progress.Hidden,
|
||||||
|
val error: Error = Error.Hidden,
|
||||||
|
val eventSink: (LeaveRoomEvent) -> Unit = {},
|
||||||
|
) {
|
||||||
|
sealed interface Confirmation {
|
||||||
|
object Hidden : Confirmation
|
||||||
|
data class Generic(val roomId: RoomId) : Confirmation
|
||||||
|
data class PrivateRoom(val roomId: RoomId) : Confirmation
|
||||||
|
data class LastUserInRoom(val roomId: RoomId) : Confirmation
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface Progress {
|
||||||
|
object Hidden : Progress
|
||||||
|
object Shown : Progress
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface Error {
|
||||||
|
object Hidden : Error
|
||||||
|
object Shown : Error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 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 io.element.android.features.leaveroom.api
|
||||||
|
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
|
||||||
|
class LeaveRoomStateProvider : PreviewParameterProvider<LeaveRoomState> {
|
||||||
|
override val values: Sequence<LeaveRoomState>
|
||||||
|
get() = sequenceOf(
|
||||||
|
LeaveRoomState(
|
||||||
|
confirmation = LeaveRoomState.Confirmation.Hidden,
|
||||||
|
progress = LeaveRoomState.Progress.Hidden,
|
||||||
|
error = LeaveRoomState.Error.Hidden,
|
||||||
|
),
|
||||||
|
LeaveRoomState(
|
||||||
|
confirmation = LeaveRoomState.Confirmation.Generic(A_ROOM_ID),
|
||||||
|
progress = LeaveRoomState.Progress.Hidden,
|
||||||
|
error = LeaveRoomState.Error.Hidden,
|
||||||
|
),
|
||||||
|
LeaveRoomState(
|
||||||
|
confirmation = LeaveRoomState.Confirmation.PrivateRoom(A_ROOM_ID),
|
||||||
|
progress = LeaveRoomState.Progress.Hidden,
|
||||||
|
error = LeaveRoomState.Error.Hidden,
|
||||||
|
),
|
||||||
|
LeaveRoomState(
|
||||||
|
confirmation = LeaveRoomState.Confirmation.LastUserInRoom(A_ROOM_ID),
|
||||||
|
progress = LeaveRoomState.Progress.Hidden,
|
||||||
|
error = LeaveRoomState.Error.Hidden,
|
||||||
|
),
|
||||||
|
LeaveRoomState(
|
||||||
|
confirmation = LeaveRoomState.Confirmation.Hidden,
|
||||||
|
progress = LeaveRoomState.Progress.Shown,
|
||||||
|
error = LeaveRoomState.Error.Hidden,
|
||||||
|
),
|
||||||
|
LeaveRoomState(
|
||||||
|
confirmation = LeaveRoomState.Confirmation.Hidden,
|
||||||
|
progress = LeaveRoomState.Progress.Hidden,
|
||||||
|
error = LeaveRoomState.Error.Shown,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val A_ROOM_ID = RoomId("!aRoomId:aDomain")
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 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 io.element.android.features.leaveroom.api
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.libraries.designsystem.components.ProgressDialog
|
||||||
|
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
||||||
|
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.ui.strings.R
|
||||||
|
import io.element.android.libraries.ui.strings.R as StringR
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LeaveRoomView(
|
||||||
|
state: LeaveRoomState
|
||||||
|
) {
|
||||||
|
LeaveRoomConfirmationDialog(state)
|
||||||
|
LeaveRoomProgressDialog(state)
|
||||||
|
LeaveRoomErrorDialog(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LeaveRoomConfirmationDialog(
|
||||||
|
state: LeaveRoomState,
|
||||||
|
) {
|
||||||
|
when (state.confirmation) {
|
||||||
|
is LeaveRoomState.Confirmation.Hidden -> {}
|
||||||
|
is LeaveRoomState.Confirmation.PrivateRoom -> LeaveRoomConfirmationDialog(
|
||||||
|
text = StringR.string.leave_room_alert_private_subtitle,
|
||||||
|
roomId = state.confirmation.roomId,
|
||||||
|
eventSink = state.eventSink,
|
||||||
|
)
|
||||||
|
|
||||||
|
is LeaveRoomState.Confirmation.LastUserInRoom -> LeaveRoomConfirmationDialog(
|
||||||
|
text = StringR.string.leave_room_alert_empty_subtitle,
|
||||||
|
roomId = state.confirmation.roomId,
|
||||||
|
eventSink = state.eventSink,
|
||||||
|
)
|
||||||
|
|
||||||
|
is LeaveRoomState.Confirmation.Generic -> LeaveRoomConfirmationDialog(
|
||||||
|
text = StringR.string.leave_room_alert_subtitle,
|
||||||
|
roomId = state.confirmation.roomId,
|
||||||
|
eventSink = state.eventSink,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LeaveRoomConfirmationDialog(
|
||||||
|
@StringRes text: Int,
|
||||||
|
roomId: RoomId,
|
||||||
|
eventSink: (LeaveRoomEvent) -> Unit,
|
||||||
|
) {
|
||||||
|
ConfirmationDialog(
|
||||||
|
content = stringResource(text),
|
||||||
|
submitText = stringResource(R.string.action_leave),
|
||||||
|
onSubmitClicked = { eventSink(LeaveRoomEvent.LeaveRoom(roomId)) },
|
||||||
|
onDismiss = { eventSink(LeaveRoomEvent.HideConfirmation) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LeaveRoomProgressDialog(
|
||||||
|
state: LeaveRoomState,
|
||||||
|
) {
|
||||||
|
when (state.progress) {
|
||||||
|
is LeaveRoomState.Progress.Hidden -> {}
|
||||||
|
is LeaveRoomState.Progress.Shown -> ProgressDialog(
|
||||||
|
text = stringResource(StringR.string.common_leaving_room),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LeaveRoomErrorDialog(
|
||||||
|
state: LeaveRoomState,
|
||||||
|
) {
|
||||||
|
when (state.error) {
|
||||||
|
is LeaveRoomState.Error.Hidden -> {}
|
||||||
|
is LeaveRoomState.Error.Shown -> ErrorDialog(
|
||||||
|
content = stringResource(StringR.string.error_unknown),
|
||||||
|
onDismiss = { state.eventSink(LeaveRoomEvent.HideError) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
internal fun LeaveRoomViewLightPreview(
|
||||||
|
@PreviewParameter(LeaveRoomStateProvider::class) state: LeaveRoomState
|
||||||
|
) = ElementPreviewLight { ContentToPreview(state) }
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
internal fun LeaveRoomViewDarkPreview(
|
||||||
|
@PreviewParameter(LeaveRoomStateProvider::class) state: LeaveRoomState
|
||||||
|
) = ElementPreviewDark { ContentToPreview(state) }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContentToPreview(state: LeaveRoomState) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.size(300.dp, 300.dp),
|
||||||
|
propagateMinConstraints = true,
|
||||||
|
) {
|
||||||
|
LeaveRoomView(state = state)
|
||||||
|
}
|
||||||
|
}
|
||||||
44
features/leaveroom/fake/build.gradle.kts
Normal file
44
features/leaveroom/fake/build.gradle.kts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("io.element.android-compose-library")
|
||||||
|
alias(libs.plugins.anvil)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "io.element.android.features.leaveroom.fake"
|
||||||
|
}
|
||||||
|
|
||||||
|
anvil {
|
||||||
|
generateDaggerFactories.set(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(projects.anvilannotations)
|
||||||
|
anvil(projects.anvilcodegen)
|
||||||
|
implementation(projects.libraries.core)
|
||||||
|
implementation(projects.libraries.architecture)
|
||||||
|
implementation(projects.libraries.matrix.api)
|
||||||
|
api(projects.features.leaveroom.api)
|
||||||
|
|
||||||
|
testImplementation(libs.test.junit)
|
||||||
|
testImplementation(libs.coroutines.test)
|
||||||
|
testImplementation(libs.coroutines.core)
|
||||||
|
testImplementation(libs.molecule.runtime)
|
||||||
|
testImplementation(libs.test.truth)
|
||||||
|
testImplementation(libs.test.turbine)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 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 io.element.android.features.leaveroom.fake
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import io.element.android.features.leaveroom.api.LeaveRoomEvent
|
||||||
|
import io.element.android.features.leaveroom.api.LeaveRoomPresenter
|
||||||
|
import io.element.android.features.leaveroom.api.LeaveRoomState
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class LeaveRoomPresenterFake @Inject constructor() : LeaveRoomPresenter {
|
||||||
|
|
||||||
|
val events = mutableListOf<LeaveRoomEvent>()
|
||||||
|
|
||||||
|
private fun handleEvent(event: LeaveRoomEvent) {
|
||||||
|
events += event
|
||||||
|
}
|
||||||
|
|
||||||
|
private var state = LeaveRoomState(eventSink = ::handleEvent)
|
||||||
|
set(value) {
|
||||||
|
field = value.copy(eventSink = ::handleEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun givenState(state: LeaveRoomState) {
|
||||||
|
this.state = state
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun present(): LeaveRoomState = state
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 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 io.element.android.features.leaveroom.fake
|
||||||
|
|
||||||
|
import com.squareup.anvil.annotations.ContributesTo
|
||||||
|
import dagger.Binds
|
||||||
|
import dagger.Module
|
||||||
|
import io.element.android.features.leaveroom.api.LeaveRoomPresenter
|
||||||
|
import io.element.android.libraries.di.SessionScope
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@ContributesTo(SessionScope::class)
|
||||||
|
interface LeaveRoomPresenterFakeModule {
|
||||||
|
@Binds
|
||||||
|
fun leaveRoomPresenter(leaveRoomPresenter: LeaveRoomPresenterFake): LeaveRoomPresenter
|
||||||
|
}
|
||||||
46
features/leaveroom/impl/build.gradle.kts
Normal file
46
features/leaveroom/impl/build.gradle.kts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("io.element.android-compose-library")
|
||||||
|
alias(libs.plugins.anvil)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "io.element.android.features.leaveroom.impl"
|
||||||
|
}
|
||||||
|
|
||||||
|
anvil {
|
||||||
|
generateDaggerFactories.set(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(projects.anvilannotations)
|
||||||
|
anvil(projects.anvilcodegen)
|
||||||
|
implementation(projects.libraries.core)
|
||||||
|
implementation(projects.libraries.architecture)
|
||||||
|
implementation(projects.libraries.matrix.api)
|
||||||
|
api(projects.features.leaveroom.api)
|
||||||
|
|
||||||
|
testImplementation(libs.test.junit)
|
||||||
|
testImplementation(libs.coroutines.test)
|
||||||
|
testImplementation(libs.coroutines.core)
|
||||||
|
testImplementation(libs.molecule.runtime)
|
||||||
|
testImplementation(libs.test.truth)
|
||||||
|
testImplementation(libs.test.turbine)
|
||||||
|
testImplementation(projects.libraries.matrix.test)
|
||||||
|
testImplementation(projects.tests.testutils)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,127 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 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 io.element.android.features.leaveroom.impl
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import io.element.android.features.leaveroom.api.LeaveRoomEvent
|
||||||
|
import io.element.android.features.leaveroom.api.LeaveRoomPresenter
|
||||||
|
import io.element.android.features.leaveroom.api.LeaveRoomState
|
||||||
|
import io.element.android.features.leaveroom.api.LeaveRoomState.Confirmation.Generic
|
||||||
|
import io.element.android.features.leaveroom.api.LeaveRoomState.Confirmation.LastUserInRoom
|
||||||
|
import io.element.android.features.leaveroom.api.LeaveRoomState.Confirmation.PrivateRoom
|
||||||
|
import io.element.android.libraries.architecture.Async
|
||||||
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
|
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
||||||
|
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
||||||
|
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class LeaveRoomPresenterImpl @Inject constructor(
|
||||||
|
private val client: MatrixClient,
|
||||||
|
private val roomMembershipObserver: RoomMembershipObserver,
|
||||||
|
private val dispatchers: CoroutineDispatchers,
|
||||||
|
) : LeaveRoomPresenter {
|
||||||
|
@Composable
|
||||||
|
override fun present(): LeaveRoomState {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val confirmation = remember { mutableStateOf<LeaveRoomState.Confirmation>(LeaveRoomState.Confirmation.Hidden) }
|
||||||
|
val progress = remember { mutableStateOf<LeaveRoomState.Progress>(LeaveRoomState.Progress.Hidden) }
|
||||||
|
val error = remember { mutableStateOf<LeaveRoomState.Error>(LeaveRoomState.Error.Hidden) }
|
||||||
|
|
||||||
|
return LeaveRoomState(
|
||||||
|
confirmation = confirmation.value,
|
||||||
|
progress = progress.value,
|
||||||
|
error = error.value,
|
||||||
|
) { event ->
|
||||||
|
when (event) {
|
||||||
|
is LeaveRoomEvent.ShowConfirmation -> scope.launch(dispatchers.io) {
|
||||||
|
showLeaveRoomAlert(
|
||||||
|
matrixClient = client,
|
||||||
|
roomId = event.roomId,
|
||||||
|
confirmation = confirmation,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is LeaveRoomEvent.HideConfirmation -> confirmation.value = LeaveRoomState.Confirmation.Hidden
|
||||||
|
is LeaveRoomEvent.LeaveRoom -> scope.launch(dispatchers.io) {
|
||||||
|
client.leaveRoom(
|
||||||
|
roomId = event.roomId,
|
||||||
|
roomMembershipObserver = roomMembershipObserver,
|
||||||
|
confirmation = confirmation,
|
||||||
|
progress = progress,
|
||||||
|
error = error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is LeaveRoomEvent.HideError -> error.value = LeaveRoomState.Error.Hidden
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun showLeaveRoomAlert(
|
||||||
|
matrixClient: MatrixClient,
|
||||||
|
roomId: RoomId,
|
||||||
|
confirmation: MutableState<LeaveRoomState.Confirmation>,
|
||||||
|
) {
|
||||||
|
matrixClient.getRoom(roomId)?.use { room ->
|
||||||
|
confirmation.value = when {
|
||||||
|
!room.isPublic -> PrivateRoom(roomId)
|
||||||
|
(room.memberCount() as? Async.Success<Int>)?.state == 1 -> LastUserInRoom(roomId)
|
||||||
|
else -> Generic(roomId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun MatrixClient.leaveRoom(
|
||||||
|
roomId: RoomId,
|
||||||
|
roomMembershipObserver: RoomMembershipObserver,
|
||||||
|
confirmation: MutableState<LeaveRoomState.Confirmation>,
|
||||||
|
progress: MutableState<LeaveRoomState.Progress>,
|
||||||
|
error: MutableState<LeaveRoomState.Error>,
|
||||||
|
) {
|
||||||
|
confirmation.value = LeaveRoomState.Confirmation.Hidden
|
||||||
|
progress.value = LeaveRoomState.Progress.Shown
|
||||||
|
getRoom(roomId)?.use { room ->
|
||||||
|
room.leave().onSuccess {
|
||||||
|
roomMembershipObserver.notifyUserLeftRoom(room.roomId)
|
||||||
|
}.onFailure {
|
||||||
|
Timber.e(it, "Error while leaving room ${room.name} - ${room.roomId}")
|
||||||
|
error.value = LeaveRoomState.Error.Shown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
progress.value = LeaveRoomState.Progress.Hidden
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun MatrixRoom.memberCount(): Async<Int> = membersStateFlow.first().let { membersState ->
|
||||||
|
when (membersState) {
|
||||||
|
MatrixRoomMembersState.Unknown -> Async.Uninitialized
|
||||||
|
is MatrixRoomMembersState.Pending -> Async.Loading(prevState = membersState.prevRoomMembers?.size)
|
||||||
|
is MatrixRoomMembersState.Error -> Async.Failure(membersState.failure, prevState = membersState.prevRoomMembers?.size)
|
||||||
|
is MatrixRoomMembersState.Ready -> Async.Success(membersState.roomMembers.count { it.membership == RoomMembershipState.JOIN })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 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 io.element.android.features.leaveroom.impl
|
||||||
|
|
||||||
|
import com.squareup.anvil.annotations.ContributesTo
|
||||||
|
import dagger.Binds
|
||||||
|
import dagger.Module
|
||||||
|
import io.element.android.features.leaveroom.api.LeaveRoomPresenter
|
||||||
|
import io.element.android.libraries.di.SessionScope
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@ContributesTo(SessionScope::class)
|
||||||
|
interface LeaveRoomPresenterImplModule {
|
||||||
|
@Binds
|
||||||
|
fun leaveRoomPresenter(leaveRoomPresenter: LeaveRoomPresenterImpl): LeaveRoomPresenter
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,248 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 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 io.element.android.features.leaveroom.impl
|
||||||
|
|
||||||
|
import app.cash.molecule.RecompositionClock
|
||||||
|
import app.cash.molecule.moleculeFlow
|
||||||
|
import app.cash.turbine.test
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.features.leaveroom.api.LeaveRoomEvent
|
||||||
|
import io.element.android.features.leaveroom.api.LeaveRoomPresenter
|
||||||
|
import io.element.android.features.leaveroom.api.LeaveRoomState
|
||||||
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
||||||
|
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||||
|
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
||||||
|
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
|
||||||
|
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||||
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
|
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||||
|
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.flow.take
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Ignore
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class LeaveRoomPresenterImplTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - initial state hides all dialogs`() = runTest {
|
||||||
|
val presenter = createPresenter()
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.confirmation).isEqualTo(LeaveRoomState.Confirmation.Hidden)
|
||||||
|
assertThat(initialState.progress).isEqualTo(LeaveRoomState.Progress.Hidden)
|
||||||
|
assertThat(initialState.error).isEqualTo(LeaveRoomState.Error.Hidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - show generic confirmation`() = runTest {
|
||||||
|
val presenter = createPresenter(
|
||||||
|
client = FakeMatrixClient().apply {
|
||||||
|
givenGetRoomResult(
|
||||||
|
roomId = A_ROOM_ID,
|
||||||
|
result = FakeMatrixRoom()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(LeaveRoomEvent.ShowConfirmation(A_ROOM_ID))
|
||||||
|
val confirmationState = awaitItem()
|
||||||
|
assertThat(confirmationState.confirmation).isEqualTo(LeaveRoomState.Confirmation.Generic(A_ROOM_ID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - show private room confirmation`() = runTest {
|
||||||
|
val presenter = createPresenter(
|
||||||
|
client = FakeMatrixClient().apply {
|
||||||
|
givenGetRoomResult(
|
||||||
|
roomId = A_ROOM_ID,
|
||||||
|
result = FakeMatrixRoom(isPublic = false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(LeaveRoomEvent.ShowConfirmation(A_ROOM_ID))
|
||||||
|
val confirmationState = awaitItem()
|
||||||
|
assertThat(confirmationState.confirmation).isEqualTo(LeaveRoomState.Confirmation.PrivateRoom(A_ROOM_ID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - show last user in room confirmation`() = runTest {
|
||||||
|
val presenter = createPresenter(
|
||||||
|
client = FakeMatrixClient().apply {
|
||||||
|
givenGetRoomResult(
|
||||||
|
roomId = A_ROOM_ID,
|
||||||
|
result = FakeMatrixRoom().apply {
|
||||||
|
givenRoomMembersState(
|
||||||
|
MatrixRoomMembersState.Ready(
|
||||||
|
listOf(
|
||||||
|
RoomMember(
|
||||||
|
userId = UserId(value = "@aUserId:aDomain"),
|
||||||
|
displayName = null,
|
||||||
|
avatarUrl = null,
|
||||||
|
membership = RoomMembershipState.JOIN,
|
||||||
|
isNameAmbiguous = false,
|
||||||
|
powerLevel = 0,
|
||||||
|
normalizedPowerLevel = 0,
|
||||||
|
isIgnored = false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(LeaveRoomEvent.ShowConfirmation(A_ROOM_ID))
|
||||||
|
val confirmationState = awaitItem()
|
||||||
|
assertThat(confirmationState.confirmation).isEqualTo(LeaveRoomState.Confirmation.LastUserInRoom(A_ROOM_ID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - leaving a room leaves the room`() = runTest {
|
||||||
|
val roomMembershipObserver = RoomMembershipObserver()
|
||||||
|
val presenter = createPresenter(
|
||||||
|
client = FakeMatrixClient().apply {
|
||||||
|
givenGetRoomResult(
|
||||||
|
roomId = A_ROOM_ID,
|
||||||
|
result = FakeMatrixRoom(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
roomMembershipObserver = roomMembershipObserver
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID))
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Membership observer should receive a 'left room' change
|
||||||
|
roomMembershipObserver.updates.take(1)
|
||||||
|
.onEach { update -> assertThat(update.change).isEqualTo(MembershipChange.LEFT) }
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - show error if leave room fails`() = runTest {
|
||||||
|
val presenter = createPresenter(
|
||||||
|
client = FakeMatrixClient().apply {
|
||||||
|
givenGetRoomResult(
|
||||||
|
roomId = A_ROOM_ID,
|
||||||
|
result = FakeMatrixRoom().apply {
|
||||||
|
givenLeaveRoomError(RuntimeException("Blimey!"))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID))
|
||||||
|
val errorState = awaitItem()
|
||||||
|
assertThat(errorState.error).isEqualTo(LeaveRoomState.Error.Shown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Ignore("TODO(Test the hiding/showing of the progress indicator too)")
|
||||||
|
fun `present - show progress indicator while leaving a room`() = runTest {
|
||||||
|
val roomMembershipObserver = RoomMembershipObserver()
|
||||||
|
val presenter = createPresenter(
|
||||||
|
client = FakeMatrixClient().apply {
|
||||||
|
givenGetRoomResult(
|
||||||
|
roomId = A_ROOM_ID,
|
||||||
|
result = FakeMatrixRoom(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID))
|
||||||
|
val progressState = awaitItem()
|
||||||
|
assertThat(progressState.progress).isEqualTo(LeaveRoomState.Progress.Shown)
|
||||||
|
val finalState = awaitItem()
|
||||||
|
assertThat(finalState.progress).isEqualTo(LeaveRoomState.Progress.Hidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Membership observer should receive a 'left room' change
|
||||||
|
roomMembershipObserver.updates.take(1)
|
||||||
|
.onEach { update -> assertThat(update.change).isEqualTo(MembershipChange.LEFT) }
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - hide error hides the error`() = runTest {
|
||||||
|
val presenter = createPresenter(
|
||||||
|
client = FakeMatrixClient().apply {
|
||||||
|
givenGetRoomResult(
|
||||||
|
roomId = A_ROOM_ID,
|
||||||
|
result = FakeMatrixRoom().apply {
|
||||||
|
givenLeaveRoomError(RuntimeException("Blimey!"))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID))
|
||||||
|
val errorState = awaitItem()
|
||||||
|
assertThat(errorState.error).isEqualTo(LeaveRoomState.Error.Shown)
|
||||||
|
errorState.eventSink(LeaveRoomEvent.HideError)
|
||||||
|
val hiddenErrorState = awaitItem()
|
||||||
|
assertThat(hiddenErrorState.error).isEqualTo(LeaveRoomState.Error.Hidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createPresenter(
|
||||||
|
client: MatrixClient = FakeMatrixClient(),
|
||||||
|
roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(),
|
||||||
|
dispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
|
||||||
|
): LeaveRoomPresenter = LeaveRoomPresenterImpl(
|
||||||
|
client = client,
|
||||||
|
roomMembershipObserver = roomMembershipObserver,
|
||||||
|
dispatchers = dispatchers,
|
||||||
|
)
|
||||||
|
|
@ -45,6 +45,7 @@ dependencies {
|
||||||
api(projects.libraries.usersearch.api)
|
api(projects.libraries.usersearch.api)
|
||||||
api(projects.services.apperror.api)
|
api(projects.services.apperror.api)
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
|
implementation(projects.features.leaveroom.api)
|
||||||
|
|
||||||
testImplementation(libs.test.junit)
|
testImplementation(libs.test.junit)
|
||||||
testImplementation(libs.coroutines.test)
|
testImplementation(libs.coroutines.test)
|
||||||
|
|
@ -54,6 +55,7 @@ dependencies {
|
||||||
testImplementation(projects.libraries.matrix.test)
|
testImplementation(projects.libraries.matrix.test)
|
||||||
testImplementation(projects.libraries.usersearch.test)
|
testImplementation(projects.libraries.usersearch.test)
|
||||||
testImplementation(projects.tests.testutils)
|
testImplementation(projects.tests.testutils)
|
||||||
|
testImplementation(projects.features.leaveroom.fake)
|
||||||
|
|
||||||
ksp(libs.showkase.processor)
|
ksp(libs.showkase.processor)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,5 @@
|
||||||
package io.element.android.features.roomdetails.impl
|
package io.element.android.features.roomdetails.impl
|
||||||
|
|
||||||
sealed interface RoomDetailsEvent {
|
sealed interface RoomDetailsEvent {
|
||||||
data class LeaveRoom(val needsConfirmation: Boolean) : RoomDetailsEvent
|
object LeaveRoom : RoomDetailsEvent
|
||||||
object ClearLeaveRoomWarning : RoomDetailsEvent
|
|
||||||
object ClearError : RoomDetailsEvent
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,44 +18,33 @@ package io.element.android.features.roomdetails.impl
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.MutableState
|
|
||||||
import androidx.compose.runtime.State
|
import androidx.compose.runtime.State
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import io.element.android.features.leaveroom.api.LeaveRoomEvent
|
||||||
|
import io.element.android.features.leaveroom.api.LeaveRoomPresenter
|
||||||
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
|
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.architecture.Async
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||||
import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
|
import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class RoomDetailsPresenter @Inject constructor(
|
class RoomDetailsPresenter @Inject constructor(
|
||||||
private val room: MatrixRoom,
|
private val room: MatrixRoom,
|
||||||
private val roomMembershipObserver: RoomMembershipObserver,
|
|
||||||
private val coroutineDispatchers: CoroutineDispatchers,
|
|
||||||
private val roomMembersDetailsPresenterFactory: RoomMemberDetailsPresenter.Factory,
|
private val roomMembersDetailsPresenterFactory: RoomMemberDetailsPresenter.Factory,
|
||||||
|
private val leaveRoomPresenter: LeaveRoomPresenter,
|
||||||
) : Presenter<RoomDetailsState> {
|
) : Presenter<RoomDetailsState> {
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): RoomDetailsState {
|
override fun present(): RoomDetailsState {
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val leaveRoomState = leaveRoomPresenter.present()
|
||||||
val leaveRoomWarning = remember {
|
|
||||||
mutableStateOf<LeaveRoomWarning?>(null)
|
|
||||||
}
|
|
||||||
val error = remember {
|
|
||||||
mutableStateOf<RoomDetailsError?>(null)
|
|
||||||
}
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
room.updateMembers()
|
room.updateMembers()
|
||||||
}
|
}
|
||||||
|
|
@ -69,16 +58,8 @@ class RoomDetailsPresenter @Inject constructor(
|
||||||
|
|
||||||
fun handleEvents(event: RoomDetailsEvent) {
|
fun handleEvents(event: RoomDetailsEvent) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is RoomDetailsEvent.LeaveRoom -> {
|
is RoomDetailsEvent.LeaveRoom ->
|
||||||
coroutineScope.leaveRoom(
|
leaveRoomState.eventSink(LeaveRoomEvent.ShowConfirmation(room.roomId))
|
||||||
needsConfirmation = event.needsConfirmation,
|
|
||||||
memberCount = memberCount,
|
|
||||||
leaveRoomWarning = leaveRoomWarning,
|
|
||||||
error = error,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
RoomDetailsEvent.ClearLeaveRoomWarning -> leaveRoomWarning.value = null
|
|
||||||
RoomDetailsEvent.ClearError -> error.value = null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -93,10 +74,9 @@ class RoomDetailsPresenter @Inject constructor(
|
||||||
memberCount = memberCount,
|
memberCount = memberCount,
|
||||||
isEncrypted = room.isEncrypted,
|
isEncrypted = room.isEncrypted,
|
||||||
canInvite = canInvite,
|
canInvite = canInvite,
|
||||||
displayLeaveRoomWarning = leaveRoomWarning.value,
|
|
||||||
error = error.value,
|
|
||||||
roomType = roomType.value,
|
roomType = roomType.value,
|
||||||
roomMemberDetailsState = roomMemberDetailsState,
|
roomMemberDetailsState = roomMemberDetailsState,
|
||||||
|
leaveRoomState = leaveRoomState,
|
||||||
eventSink = ::handleEvents,
|
eventSink = ::handleEvents,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -141,27 +121,4 @@ class RoomDetailsPresenter @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun CoroutineScope.leaveRoom(
|
|
||||||
needsConfirmation: Boolean,
|
|
||||||
memberCount: Async<Int>,
|
|
||||||
leaveRoomWarning: MutableState<LeaveRoomWarning?>,
|
|
||||||
error: MutableState<RoomDetailsError?>,
|
|
||||||
) = launch(coroutineDispatchers.io) {
|
|
||||||
if (needsConfirmation) {
|
|
||||||
leaveRoomWarning.value = LeaveRoomWarning.computeLeaveRoomWarning(room.isPublic, memberCount)
|
|
||||||
} else {
|
|
||||||
room.leave()
|
|
||||||
.onSuccess {
|
|
||||||
roomMembershipObserver.notifyUserLeftRoom(room.roomId)
|
|
||||||
}.onFailure {
|
|
||||||
error.value = RoomDetailsError.AlertGeneric
|
|
||||||
}
|
|
||||||
leaveRoomWarning.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
|
|
||||||
package io.element.android.features.roomdetails.impl
|
package io.element.android.features.roomdetails.impl
|
||||||
|
|
||||||
|
import io.element.android.features.leaveroom.api.LeaveRoomState
|
||||||
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState
|
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.architecture.Async
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||||
|
|
@ -28,11 +29,10 @@ data class RoomDetailsState(
|
||||||
val roomTopic: String?,
|
val roomTopic: String?,
|
||||||
val memberCount: Async<Int>,
|
val memberCount: Async<Int>,
|
||||||
val isEncrypted: Boolean,
|
val isEncrypted: Boolean,
|
||||||
val displayLeaveRoomWarning: LeaveRoomWarning?,
|
|
||||||
val error: RoomDetailsError?,
|
|
||||||
val roomType: RoomDetailsType,
|
val roomType: RoomDetailsType,
|
||||||
val roomMemberDetailsState: RoomMemberDetailsState?,
|
val roomMemberDetailsState: RoomMemberDetailsState?,
|
||||||
val canInvite: Boolean,
|
val canInvite: Boolean,
|
||||||
|
val leaveRoomState: LeaveRoomState,
|
||||||
val eventSink: (RoomDetailsEvent) -> Unit
|
val eventSink: (RoomDetailsEvent) -> Unit
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -40,23 +40,3 @@ sealed interface RoomDetailsType {
|
||||||
object Room : RoomDetailsType
|
object Room : RoomDetailsType
|
||||||
data class Dm(val roomMember: RoomMember) : RoomDetailsType
|
data class Dm(val roomMember: RoomMember) : RoomDetailsType
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed class LeaveRoomWarning {
|
|
||||||
object Generic : LeaveRoomWarning()
|
|
||||||
object PrivateRoom : LeaveRoomWarning()
|
|
||||||
object LastUserInRoom : LeaveRoomWarning()
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun computeLeaveRoomWarning(isPublic: Boolean, memberCount: Async<Int>): LeaveRoomWarning {
|
|
||||||
return when {
|
|
||||||
!isPublic -> PrivateRoom
|
|
||||||
(memberCount as? Async.Success<Int>)?.state == 1 -> LastUserInRoom
|
|
||||||
else -> Generic
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sealed interface RoomDetailsError {
|
|
||||||
object AlertGeneric : RoomDetailsError
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
package io.element.android.features.roomdetails.impl
|
package io.element.android.features.roomdetails.impl
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
import io.element.android.features.leaveroom.api.LeaveRoomState
|
||||||
import io.element.android.features.roomdetails.impl.members.details.aRoomMemberDetailsState
|
import io.element.android.features.roomdetails.impl.members.details.aRoomMemberDetailsState
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.architecture.Async
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
|
@ -70,11 +71,10 @@ fun aRoomDetailsState() = RoomDetailsState(
|
||||||
"|| MAI iki/Marketing...",
|
"|| MAI iki/Marketing...",
|
||||||
memberCount = Async.Success(32),
|
memberCount = Async.Success(32),
|
||||||
isEncrypted = true,
|
isEncrypted = true,
|
||||||
displayLeaveRoomWarning = null,
|
|
||||||
error = null,
|
|
||||||
canInvite = false,
|
canInvite = false,
|
||||||
roomType = RoomDetailsType.Room,
|
roomType = RoomDetailsType.Room,
|
||||||
roomMemberDetailsState = null,
|
roomMemberDetailsState = null,
|
||||||
|
leaveRoomState = LeaveRoomState(),
|
||||||
eventSink = {}
|
eventSink = {}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.features.roomdetails.impl.blockuser.BlockUserDialogs
|
import io.element.android.features.roomdetails.impl.blockuser.BlockUserDialogs
|
||||||
import io.element.android.features.roomdetails.impl.blockuser.BlockUserSection
|
import io.element.android.features.roomdetails.impl.blockuser.BlockUserSection
|
||||||
|
import io.element.android.features.leaveroom.api.LeaveRoomView
|
||||||
import io.element.android.features.roomdetails.impl.members.details.RoomMemberHeaderSection
|
import io.element.android.features.roomdetails.impl.members.details.RoomMemberHeaderSection
|
||||||
import io.element.android.features.roomdetails.impl.members.details.RoomMemberMainActionsSection
|
import io.element.android.features.roomdetails.impl.members.details.RoomMemberMainActionsSection
|
||||||
import io.element.android.libraries.architecture.isLoading
|
import io.element.android.libraries.architecture.isLoading
|
||||||
|
|
@ -56,8 +57,6 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||||
import io.element.android.libraries.designsystem.components.button.MainActionButton
|
import io.element.android.libraries.designsystem.components.button.MainActionButton
|
||||||
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
|
||||||
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
|
||||||
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
|
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
|
||||||
import io.element.android.libraries.designsystem.components.preferences.PreferenceText
|
import io.element.android.libraries.designsystem.components.preferences.PreferenceText
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
|
|
@ -68,7 +67,6 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||||
import io.element.android.libraries.ui.strings.R as StringR
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -97,6 +95,8 @@ fun RoomDetailsView(
|
||||||
.consumeWindowInsets(padding)
|
.consumeWindowInsets(padding)
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
) {
|
) {
|
||||||
|
LeaveRoomView(state = state.leaveRoomState)
|
||||||
|
|
||||||
when (state.roomType) {
|
when (state.roomType) {
|
||||||
RoomDetailsType.Room -> {
|
RoomDetailsType.Room -> {
|
||||||
RoomHeaderSection(
|
RoomHeaderSection(
|
||||||
|
|
@ -145,23 +145,8 @@ fun RoomDetailsView(
|
||||||
}
|
}
|
||||||
|
|
||||||
OtherActionsSection(onLeaveRoom = {
|
OtherActionsSection(onLeaveRoom = {
|
||||||
state.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true))
|
state.eventSink(RoomDetailsEvent.LeaveRoom)
|
||||||
})
|
})
|
||||||
|
|
||||||
if (state.displayLeaveRoomWarning != null) {
|
|
||||||
ConfirmLeaveRoomDialog(
|
|
||||||
leaveRoomWarning = state.displayLeaveRoomWarning,
|
|
||||||
onConfirmLeave = { state.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = false)) },
|
|
||||||
onDismiss = { state.eventSink(RoomDetailsEvent.ClearLeaveRoomWarning) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.error != null) {
|
|
||||||
ErrorDialog(
|
|
||||||
content = stringResource(StringR.string.error_unknown),
|
|
||||||
onDismiss = { state.eventSink(RoomDetailsEvent.ClearError) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -260,27 +245,6 @@ internal fun OtherActionsSection(onLeaveRoom: () -> Unit, modifier: Modifier = M
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun ConfirmLeaveRoomDialog(
|
|
||||||
leaveRoomWarning: LeaveRoomWarning,
|
|
||||||
onConfirmLeave: () -> Unit,
|
|
||||||
onDismiss: () -> Unit
|
|
||||||
) {
|
|
||||||
val content = stringResource(
|
|
||||||
when (leaveRoomWarning) {
|
|
||||||
LeaveRoomWarning.PrivateRoom -> StringR.string.leave_room_alert_private_subtitle
|
|
||||||
LeaveRoomWarning.LastUserInRoom -> StringR.string.leave_room_alert_empty_subtitle
|
|
||||||
LeaveRoomWarning.Generic -> StringR.string.leave_room_alert_subtitle
|
|
||||||
}
|
|
||||||
)
|
|
||||||
ConfirmationDialog(
|
|
||||||
content = content,
|
|
||||||
submitText = stringResource(StringR.string.action_leave),
|
|
||||||
onSubmitClicked = onConfirmLeave,
|
|
||||||
onDismiss = onDismiss,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@LargeHeightPreview
|
@LargeHeightPreview
|
||||||
@Composable
|
@Composable
|
||||||
fun RoomDetailsLightPreview(@PreviewParameter(RoomDetailsStateProvider::class) state: RoomDetailsState) =
|
fun RoomDetailsLightPreview(@PreviewParameter(RoomDetailsStateProvider::class) state: RoomDetailsState) =
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,7 @@ import app.cash.molecule.RecompositionClock
|
||||||
import app.cash.molecule.moleculeFlow
|
import app.cash.molecule.moleculeFlow
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth
|
import com.google.common.truth.Truth
|
||||||
import io.element.android.features.roomdetails.impl.LeaveRoomWarning
|
import io.element.android.features.leaveroom.fake.LeaveRoomPresenterFake
|
||||||
import io.element.android.features.roomdetails.impl.RoomDetailsEvent
|
|
||||||
import io.element.android.features.roomdetails.impl.RoomDetailsPresenter
|
import io.element.android.features.roomdetails.impl.RoomDetailsPresenter
|
||||||
import io.element.android.features.roomdetails.impl.RoomDetailsType
|
import io.element.android.features.roomdetails.impl.RoomDetailsType
|
||||||
import io.element.android.features.roomdetails.impl.members.aRoomMember
|
import io.element.android.features.roomdetails.impl.members.aRoomMember
|
||||||
|
|
@ -34,7 +33,6 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
|
|
||||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||||
import io.element.android.libraries.matrix.test.A_ROOM_NAME
|
import io.element.android.libraries.matrix.test.A_ROOM_NAME
|
||||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
|
|
@ -44,9 +42,6 @@ import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.collect
|
|
||||||
import kotlinx.coroutines.flow.onEach
|
|
||||||
import kotlinx.coroutines.flow.take
|
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
|
|
@ -62,7 +57,7 @@ class RoomDetailsPresenterTests {
|
||||||
return RoomMemberDetailsPresenter(aMatrixClient(), room, roomMemberId)
|
return RoomMemberDetailsPresenter(aMatrixClient(), room, roomMemberId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return RoomDetailsPresenter(room, roomMembershipObserver, testCoroutineDispatchers, roomMemberDetailsPresenterFactory)
|
return RoomDetailsPresenter(room, roomMemberDetailsPresenterFactory, LeaveRoomPresenterFake())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -155,103 +150,6 @@ class RoomDetailsPresenterTests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `present - Leave with confirmation on private room shows a specific warning`() = runTest {
|
|
||||||
val room = aMatrixRoom(isPublic = false).apply {
|
|
||||||
givenRoomMembersState(MatrixRoomMembersState.Ready(emptyList()))
|
|
||||||
}
|
|
||||||
val presenter = aRoomDetailsPresenter(room)
|
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
val initialState = awaitItem()
|
|
||||||
skipItems(1)
|
|
||||||
|
|
||||||
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true))
|
|
||||||
val confirmationState = awaitItem()
|
|
||||||
Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.PrivateRoom)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `present - Leave with confirmation on empty room shows a specific warning`() = runTest {
|
|
||||||
val room = aMatrixRoom().apply {
|
|
||||||
givenRoomMembersState(MatrixRoomMembersState.Ready(listOf(aRoomMember())))
|
|
||||||
}
|
|
||||||
val presenter = aRoomDetailsPresenter(room)
|
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
val initialState = awaitItem()
|
|
||||||
skipItems(1)
|
|
||||||
|
|
||||||
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true))
|
|
||||||
val confirmationState = awaitItem()
|
|
||||||
Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.LastUserInRoom)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `present - Leave with confirmation shows a generic warning`() = runTest {
|
|
||||||
val room = aMatrixRoom().apply {
|
|
||||||
givenRoomMembersState(MatrixRoomMembersState.Ready(emptyList()))
|
|
||||||
}
|
|
||||||
val presenter = aRoomDetailsPresenter(room)
|
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
val initialState = awaitItem()
|
|
||||||
skipItems(1)
|
|
||||||
|
|
||||||
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true))
|
|
||||||
val confirmationState = awaitItem()
|
|
||||||
Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.Generic)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `present - Leave without confirmation leaves the room`() = runTest {
|
|
||||||
val room = aMatrixRoom().apply {
|
|
||||||
givenRoomMembersState(MatrixRoomMembersState.Ready(emptyList()))
|
|
||||||
}
|
|
||||||
val presenter = aRoomDetailsPresenter(room)
|
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
val initialState = awaitItem()
|
|
||||||
skipItems(1)
|
|
||||||
|
|
||||||
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = false))
|
|
||||||
|
|
||||||
cancelAndIgnoreRemainingEvents()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Membership observer should receive a 'left room' change
|
|
||||||
roomMembershipObserver.updates.take(1)
|
|
||||||
.onEach { update -> Truth.assertThat(update.change).isEqualTo(MembershipChange.LEFT) }
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `present - ClearError removes any error present`() = runTest {
|
|
||||||
val room = aMatrixRoom().apply {
|
|
||||||
givenLeaveRoomError(Throwable())
|
|
||||||
}
|
|
||||||
val presenter = aRoomDetailsPresenter(room)
|
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
val initialState = awaitItem()
|
|
||||||
skipItems(1)
|
|
||||||
|
|
||||||
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = false))
|
|
||||||
val errorState = awaitItem()
|
|
||||||
Truth.assertThat(errorState.error).isNotNull()
|
|
||||||
errorState.eventSink(RoomDetailsEvent.ClearError)
|
|
||||||
Truth.assertThat(awaitItem().error).isNull()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - initial state when user can invite others to room`() = runTest {
|
fun `present - initial state when user can invite others to room`() = runTest {
|
||||||
val room = aMatrixRoom().apply {
|
val room = aMatrixRoom().apply {
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ interface RoomListEntryPoint : FeatureEntryPoint {
|
||||||
fun onSettingsClicked()
|
fun onSettingsClicked()
|
||||||
fun onSessionVerificationClicked()
|
fun onSessionVerificationClicked()
|
||||||
fun onInvitesClicked()
|
fun onInvitesClicked()
|
||||||
|
fun onRoomSettingsClicked(roomId: RoomId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ dependencies {
|
||||||
implementation(projects.libraries.dateformatter.api)
|
implementation(projects.libraries.dateformatter.api)
|
||||||
implementation(projects.features.invitelist.api)
|
implementation(projects.features.invitelist.api)
|
||||||
implementation(projects.features.networkmonitor.api)
|
implementation(projects.features.networkmonitor.api)
|
||||||
|
implementation(projects.features.leaveroom.api)
|
||||||
implementation(libs.accompanist.placeholder)
|
implementation(libs.accompanist.placeholder)
|
||||||
api(projects.features.roomlist.api)
|
api(projects.features.roomlist.api)
|
||||||
ksp(libs.showkase.processor)
|
ksp(libs.showkase.processor)
|
||||||
|
|
@ -66,6 +67,7 @@ dependencies {
|
||||||
testImplementation(projects.features.invitelist.test)
|
testImplementation(projects.features.invitelist.test)
|
||||||
testImplementation(projects.features.networkmonitor.test)
|
testImplementation(projects.features.networkmonitor.test)
|
||||||
testImplementation(projects.tests.testutils)
|
testImplementation(projects.tests.testutils)
|
||||||
|
testImplementation(projects.features.leaveroom.fake)
|
||||||
|
|
||||||
androidTestImplementation(libs.test.junitext)
|
androidTestImplementation(libs.test.junitext)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,148 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 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 io.element.android.features.roomlist.impl
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.Settings
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.libraries.designsystem.VectorIcons
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
|
import io.element.android.libraries.designsystem.theme.ElementTheme
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.ui.strings.R as StringR
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun RoomListContextMenu(
|
||||||
|
contextMenu: RoomListState.ContextMenu.Shown,
|
||||||
|
eventSink: (RoomListEvents) -> Unit,
|
||||||
|
onRoomSettingsClicked: (roomId: RoomId) -> Unit,
|
||||||
|
) {
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = { eventSink(RoomListEvents.HideContextMenu) },
|
||||||
|
) {
|
||||||
|
RoomListModalBottomSheetContent(
|
||||||
|
contextMenu = contextMenu,
|
||||||
|
onRoomSettingsClicked = {
|
||||||
|
eventSink(RoomListEvents.HideContextMenu)
|
||||||
|
onRoomSettingsClicked(it)
|
||||||
|
},
|
||||||
|
onLeaveRoomClicked = {
|
||||||
|
eventSink(RoomListEvents.HideContextMenu)
|
||||||
|
eventSink(RoomListEvents.LeaveRoom(contextMenu.roomId))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RoomListModalBottomSheetContent(
|
||||||
|
contextMenu: RoomListState.ContextMenu.Shown,
|
||||||
|
onRoomSettingsClicked: (roomId: RoomId) -> Unit,
|
||||||
|
onLeaveRoomClicked: (roomId: RoomId) -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
text = contextMenu.roomName,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(text = stringResource(id = StringR.string.common_settings))
|
||||||
|
},
|
||||||
|
modifier = Modifier.clickable { onRoomSettingsClicked(contextMenu.roomId) },
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.Settings,
|
||||||
|
contentDescription = stringResource(id = StringR.string.common_settings),
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = StringR.string.action_leave_room),
|
||||||
|
color = ElementTheme.colors.textActionCritical,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.clickable { onLeaveRoomClicked(contextMenu.roomId) },
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
resourceId = VectorIcons.DoorOpen,
|
||||||
|
contentDescription = stringResource(id = StringR.string.action_leave_room),
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
tint = ElementTheme.colors.textActionCritical,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO This component should be seen in [RoomListView] @Preview but it doesn't show up.
|
||||||
|
// see: https://issuetracker.google.com/issues/283843380
|
||||||
|
// Remove this preview when the issue is fixed.
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
internal fun RoomListModalBottomSheetContentLightPreview() =
|
||||||
|
ElementPreviewLight { ContentToPreview() }
|
||||||
|
|
||||||
|
// TODO This component should be seen in [RoomListView] @Preview but it doesn't show up.
|
||||||
|
// see: https://issuetracker.google.com/issues/283843380
|
||||||
|
// Remove this preview when the issue is fixed.
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
internal fun RoomListModalBottomSheetContentDarkPreview() =
|
||||||
|
ElementPreviewDark { ContentToPreview() }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContentToPreview() {
|
||||||
|
RoomListModalBottomSheetContent(
|
||||||
|
contextMenu = RoomListState.ContextMenu.Shown(
|
||||||
|
roomId = RoomId(value = "!aRoom:aDomain"),
|
||||||
|
roomName = "aRoom"
|
||||||
|
),
|
||||||
|
onRoomSettingsClicked = {},
|
||||||
|
onLeaveRoomClicked = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -16,9 +16,15 @@
|
||||||
|
|
||||||
package io.element.android.features.roomlist.impl
|
package io.element.android.features.roomlist.impl
|
||||||
|
|
||||||
|
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
|
||||||
sealed interface RoomListEvents {
|
sealed interface RoomListEvents {
|
||||||
data class UpdateFilter(val newFilter: String) : RoomListEvents
|
data class UpdateFilter(val newFilter: String) : RoomListEvents
|
||||||
data class UpdateVisibleRange(val range: IntRange) : RoomListEvents
|
data class UpdateVisibleRange(val range: IntRange) : RoomListEvents
|
||||||
object DismissRequestVerificationPrompt : RoomListEvents
|
object DismissRequestVerificationPrompt : RoomListEvents
|
||||||
object ToggleSearchResults : RoomListEvents
|
object ToggleSearchResults : RoomListEvents
|
||||||
|
data class ShowContextMenu(val roomListRoomSummary: RoomListRoomSummary) : RoomListEvents
|
||||||
|
object HideContextMenu : RoomListEvents
|
||||||
|
data class LeaveRoom(val roomId: RoomId) : RoomListEvents
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,17 +56,22 @@ class RoomListNode @AssistedInject constructor(
|
||||||
plugins<RoomListEntryPoint.Callback>().forEach { it.onInvitesClicked() }
|
plugins<RoomListEntryPoint.Callback>().forEach { it.onInvitesClicked() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun onRoomSettingsClicked(roomId: RoomId) {
|
||||||
|
plugins<RoomListEntryPoint.Callback>().forEach { it.onRoomSettingsClicked(roomId) }
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
RoomListView(
|
RoomListView(
|
||||||
state = state,
|
state = state,
|
||||||
modifier = modifier,
|
|
||||||
onRoomClicked = this::onRoomClicked,
|
onRoomClicked = this::onRoomClicked,
|
||||||
onOpenSettings = this::onOpenSettings,
|
onSettingsClicked = this::onOpenSettings,
|
||||||
onCreateRoomClicked = this::onCreateRoomClicked,
|
onCreateRoomClicked = this::onCreateRoomClicked,
|
||||||
onVerifyClicked = this::onSessionVerificationClicked,
|
onVerifyClicked = this::onSessionVerificationClicked,
|
||||||
onInvitesClicked = this::onInvitesClicked,
|
onInvitesClicked = this::onInvitesClicked,
|
||||||
|
onRoomSettingsClicked = this::onRoomSettingsClicked,
|
||||||
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
||||||
import io.element.android.features.networkmonitor.api.NetworkStatus
|
import io.element.android.features.networkmonitor.api.NetworkStatus
|
||||||
|
import io.element.android.features.leaveroom.api.LeaveRoomEvent
|
||||||
|
import io.element.android.features.leaveroom.api.LeaveRoomPresenter
|
||||||
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
||||||
import io.element.android.features.roomlist.impl.model.RoomListRoomSummaryPlaceholders
|
import io.element.android.features.roomlist.impl.model.RoomListRoomSummaryPlaceholders
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
|
@ -62,10 +64,12 @@ class RoomListPresenter @Inject constructor(
|
||||||
private val networkMonitor: NetworkMonitor,
|
private val networkMonitor: NetworkMonitor,
|
||||||
private val snackbarDispatcher: SnackbarDispatcher,
|
private val snackbarDispatcher: SnackbarDispatcher,
|
||||||
private val inviteStateDataSource: InviteStateDataSource,
|
private val inviteStateDataSource: InviteStateDataSource,
|
||||||
|
private val leaveRoomPresenter: LeaveRoomPresenter,
|
||||||
) : Presenter<RoomListState> {
|
) : Presenter<RoomListState> {
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): RoomListState {
|
override fun present(): RoomListState {
|
||||||
|
val leaveRoomState = leaveRoomPresenter.present()
|
||||||
val matrixUser: MutableState<MatrixUser?> = rememberSaveable {
|
val matrixUser: MutableState<MatrixUser?> = rememberSaveable {
|
||||||
mutableStateOf(null)
|
mutableStateOf(null)
|
||||||
}
|
}
|
||||||
|
|
@ -97,6 +101,8 @@ class RoomListPresenter @Inject constructor(
|
||||||
|
|
||||||
var displaySearchResults by rememberSaveable { mutableStateOf(false) }
|
var displaySearchResults by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
var contextMenu by remember { mutableStateOf<RoomListState.ContextMenu>(RoomListState.ContextMenu.Hidden) }
|
||||||
|
|
||||||
fun handleEvents(event: RoomListEvents) {
|
fun handleEvents(event: RoomListEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is RoomListEvents.UpdateFilter -> filter = event.newFilter
|
is RoomListEvents.UpdateFilter -> filter = event.newFilter
|
||||||
|
|
@ -108,6 +114,14 @@ class RoomListPresenter @Inject constructor(
|
||||||
}
|
}
|
||||||
displaySearchResults = !displaySearchResults
|
displaySearchResults = !displaySearchResults
|
||||||
}
|
}
|
||||||
|
is RoomListEvents.ShowContextMenu -> {
|
||||||
|
contextMenu = RoomListState.ContextMenu.Shown(
|
||||||
|
roomId = event.roomListRoomSummary.roomId,
|
||||||
|
roomName = event.roomListRoomSummary.name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is RoomListEvents.HideContextMenu -> contextMenu = RoomListState.ContextMenu.Hidden
|
||||||
|
is RoomListEvents.LeaveRoom -> leaveRoomState.eventSink(LeaveRoomEvent.ShowConfirmation(event.roomId))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -132,6 +146,8 @@ class RoomListPresenter @Inject constructor(
|
||||||
hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online,
|
hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online,
|
||||||
invitesState = inviteStateDataSource.inviteState(),
|
invitesState = inviteStateDataSource.inviteState(),
|
||||||
displaySearchResults = displaySearchResults,
|
displaySearchResults = displaySearchResults,
|
||||||
|
contextMenu = contextMenu,
|
||||||
|
leaveRoomState = leaveRoomState,
|
||||||
eventSink = ::handleEvents
|
eventSink = ::handleEvents
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,10 @@
|
||||||
package io.element.android.features.roomlist.impl
|
package io.element.android.features.roomlist.impl
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
|
import io.element.android.features.leaveroom.api.LeaveRoomState
|
||||||
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
||||||
import io.element.android.libraries.designsystem.utils.SnackbarMessage
|
import io.element.android.libraries.designsystem.utils.SnackbarMessage
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
|
|
@ -33,8 +35,18 @@ data class RoomListState(
|
||||||
val snackbarMessage: SnackbarMessage?,
|
val snackbarMessage: SnackbarMessage?,
|
||||||
val invitesState: InvitesState,
|
val invitesState: InvitesState,
|
||||||
val displaySearchResults: Boolean,
|
val displaySearchResults: Boolean,
|
||||||
val eventSink: (RoomListEvents) -> Unit
|
val contextMenu: ContextMenu,
|
||||||
)
|
val leaveRoomState: LeaveRoomState,
|
||||||
|
val eventSink: (RoomListEvents) -> Unit,
|
||||||
|
) {
|
||||||
|
sealed interface ContextMenu {
|
||||||
|
object Hidden : ContextMenu
|
||||||
|
data class Shown(
|
||||||
|
val roomId: RoomId,
|
||||||
|
val roomName: String,
|
||||||
|
) : ContextMenu
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum class InvitesState {
|
enum class InvitesState {
|
||||||
NoInvites,
|
NoInvites,
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
package io.element.android.features.roomlist.impl
|
package io.element.android.features.roomlist.impl
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
import io.element.android.features.leaveroom.api.LeaveRoomState
|
||||||
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
||||||
import io.element.android.features.roomlist.impl.model.RoomListRoomSummaryPlaceholders
|
import io.element.android.features.roomlist.impl.model.RoomListRoomSummaryPlaceholders
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
|
|
@ -39,6 +40,9 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
|
||||||
aRoomListState().copy(invitesState = InvitesState.NewInvites),
|
aRoomListState().copy(invitesState = InvitesState.NewInvites),
|
||||||
aRoomListState().copy(displaySearchResults = true, filter = "", filteredRoomList = persistentListOf()),
|
aRoomListState().copy(displaySearchResults = true, filter = "", filteredRoomList = persistentListOf()),
|
||||||
aRoomListState().copy(displaySearchResults = true),
|
aRoomListState().copy(displaySearchResults = true),
|
||||||
|
aRoomListState().copy(contextMenu = RoomListState.ContextMenu.Shown(
|
||||||
|
roomId = RoomId("!aRoom:aDomain"), roomName = "A nice room name"
|
||||||
|
))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -52,6 +56,8 @@ internal fun aRoomListState() = RoomListState(
|
||||||
displayVerificationPrompt = false,
|
displayVerificationPrompt = false,
|
||||||
invitesState = InvitesState.NoInvites,
|
invitesState = InvitesState.NoInvites,
|
||||||
displaySearchResults = false,
|
displaySearchResults = false,
|
||||||
|
contextMenu = RoomListState.ContextMenu.Hidden,
|
||||||
|
leaveRoomState = LeaveRoomState(),
|
||||||
eventSink = {}
|
eventSink = {}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@
|
||||||
|
|
||||||
package io.element.android.features.roomlist.impl
|
package io.element.android.features.roomlist.impl
|
||||||
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
|
@ -62,6 +61,7 @@ import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.Velocity
|
import androidx.compose.ui.unit.Velocity
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.features.leaveroom.api.LeaveRoomView
|
||||||
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
|
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
|
||||||
import io.element.android.features.roomlist.impl.components.RoomListTopBar
|
import io.element.android.features.roomlist.impl.components.RoomListTopBar
|
||||||
import io.element.android.features.roomlist.impl.components.RoomSummaryRow
|
import io.element.android.features.roomlist.impl.components.RoomSummaryRow
|
||||||
|
|
@ -88,20 +88,38 @@ import io.element.android.libraries.ui.strings.R as StringR
|
||||||
@Composable
|
@Composable
|
||||||
fun RoomListView(
|
fun RoomListView(
|
||||||
state: RoomListState,
|
state: RoomListState,
|
||||||
|
onRoomClicked: (RoomId) -> Unit,
|
||||||
|
onSettingsClicked: () -> Unit,
|
||||||
|
onVerifyClicked: () -> Unit,
|
||||||
|
onCreateRoomClicked: () -> Unit,
|
||||||
|
onInvitesClicked: () -> Unit,
|
||||||
|
onRoomSettingsClicked: (roomId: RoomId) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onRoomClicked: (RoomId) -> Unit = {},
|
|
||||||
onOpenSettings: () -> Unit = {},
|
|
||||||
onVerifyClicked: () -> Unit = {},
|
|
||||||
onCreateRoomClicked: () -> Unit = {},
|
|
||||||
onInvitesClicked: () -> Unit = {},
|
|
||||||
) {
|
) {
|
||||||
Column(modifier = modifier) {
|
Column(modifier = modifier) {
|
||||||
ConnectivityIndicatorView(isOnline = state.hasNetworkConnection)
|
ConnectivityIndicatorView(isOnline = state.hasNetworkConnection)
|
||||||
Box {
|
Box {
|
||||||
|
fun onRoomLongClicked(
|
||||||
|
roomListRoomSummary: RoomListRoomSummary
|
||||||
|
) {
|
||||||
|
state.eventSink(RoomListEvents.ShowContextMenu(roomListRoomSummary))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.contextMenu is RoomListState.ContextMenu.Shown) {
|
||||||
|
RoomListContextMenu(
|
||||||
|
contextMenu = state.contextMenu,
|
||||||
|
eventSink = state.eventSink,
|
||||||
|
onRoomSettingsClicked = onRoomSettingsClicked,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
LeaveRoomView(state = state.leaveRoomState)
|
||||||
|
|
||||||
RoomListContent(
|
RoomListContent(
|
||||||
state = state,
|
state = state,
|
||||||
onRoomClicked = onRoomClicked,
|
onRoomClicked = onRoomClicked,
|
||||||
onOpenSettings = onOpenSettings,
|
onRoomLongClicked = { onRoomLongClicked(it) },
|
||||||
|
onOpenSettings = onSettingsClicked,
|
||||||
onVerifyClicked = onVerifyClicked,
|
onVerifyClicked = onVerifyClicked,
|
||||||
onCreateRoomClicked = onCreateRoomClicked,
|
onCreateRoomClicked = onCreateRoomClicked,
|
||||||
onInvitesClicked = onInvitesClicked,
|
onInvitesClicked = onInvitesClicked,
|
||||||
|
|
@ -110,6 +128,7 @@ fun RoomListView(
|
||||||
RoomListSearchResultView(
|
RoomListSearchResultView(
|
||||||
state = state,
|
state = state,
|
||||||
onRoomClicked = onRoomClicked,
|
onRoomClicked = onRoomClicked,
|
||||||
|
onRoomLongClicked = { onRoomLongClicked(it) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(MaterialTheme.colorScheme.background)
|
.background(MaterialTheme.colorScheme.background)
|
||||||
|
|
@ -125,12 +144,12 @@ fun RoomListContent(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onVerifyClicked: () -> Unit = {},
|
onVerifyClicked: () -> Unit = {},
|
||||||
onRoomClicked: (RoomId) -> Unit = {},
|
onRoomClicked: (RoomId) -> Unit = {},
|
||||||
|
onRoomLongClicked: (RoomListRoomSummary) -> Unit = {},
|
||||||
onOpenSettings: () -> Unit = {},
|
onOpenSettings: () -> Unit = {},
|
||||||
onCreateRoomClicked: () -> Unit = {},
|
onCreateRoomClicked: () -> Unit = {},
|
||||||
onInvitesClicked: () -> Unit = {},
|
onInvitesClicked: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
fun onRoomClicked(room: RoomListRoomSummary) {
|
fun onRoomClicked(room: RoomListRoomSummary) {
|
||||||
if (room.roomId == null) return
|
|
||||||
onRoomClicked(room.roomId)
|
onRoomClicked(room.roomId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -237,7 +256,11 @@ fun RoomListContent(
|
||||||
items = state.roomList,
|
items = state.roomList,
|
||||||
contentType = { room -> room.contentType() },
|
contentType = { room -> room.contentType() },
|
||||||
) { room ->
|
) { room ->
|
||||||
RoomSummaryRow(room = room, onClick = ::onRoomClicked)
|
RoomSummaryRow(
|
||||||
|
room = room,
|
||||||
|
onClick = ::onRoomClicked,
|
||||||
|
onLongClick = onRoomLongClicked,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -339,13 +362,25 @@ internal fun RoomListViewDarkPreview(@PreviewParameter(RoomListStateProvider::cl
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ContentToPreview(state: RoomListState) {
|
private fun ContentToPreview(state: RoomListState) {
|
||||||
RoomListView(state)
|
RoomListView(
|
||||||
|
state = state,
|
||||||
|
onRoomClicked = {},
|
||||||
|
onSettingsClicked = {},
|
||||||
|
onVerifyClicked = {},
|
||||||
|
onCreateRoomClicked = {},
|
||||||
|
onInvitesClicked = {},
|
||||||
|
onRoomSettingsClicked = {}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
internal fun RoomListSearchResultContentPreview() {
|
internal fun RoomListSearchResultContentPreview() {
|
||||||
ElementPreviewLight {
|
ElementPreviewLight {
|
||||||
RoomListSearchResultContent(state = aRoomListState(), onRoomClicked = {})
|
RoomListSearchResultContent(
|
||||||
|
state = aRoomListState(),
|
||||||
|
onRoomClicked = {},
|
||||||
|
onRoomLongClicked = {}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,9 @@
|
||||||
|
|
||||||
package io.element.android.features.roomlist.impl.components
|
package io.element.android.features.roomlist.impl.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
|
@ -70,17 +71,20 @@ import io.element.android.libraries.designsystem.theme.roomListUnreadIndicator
|
||||||
|
|
||||||
private val minHeight = 72.dp
|
private val minHeight = 72.dp
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
internal fun RoomSummaryRow(
|
internal fun RoomSummaryRow(
|
||||||
room: RoomListRoomSummary,
|
room: RoomListRoomSummary,
|
||||||
|
onClick: (RoomListRoomSummary) -> Unit,
|
||||||
|
onLongClick: (RoomListRoomSummary) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onClick: (RoomListRoomSummary) -> Unit = {},
|
|
||||||
) {
|
) {
|
||||||
val clickModifier = if (room.isPlaceholder) {
|
val clickModifier = if (room.isPlaceholder) {
|
||||||
modifier
|
modifier
|
||||||
} else {
|
} else {
|
||||||
modifier.clickable(
|
modifier.combinedClickable(
|
||||||
onClick = { onClick(room) },
|
onClick = { onClick(room) },
|
||||||
|
onLongClick = { onLongClick(room) },
|
||||||
indication = rememberRipple(),
|
indication = rememberRipple(),
|
||||||
interactionSource = remember { MutableInteractionSource() }
|
interactionSource = remember { MutableInteractionSource() }
|
||||||
)
|
)
|
||||||
|
|
@ -214,5 +218,9 @@ internal fun RoomSummaryRowDarkPreview(@PreviewParameter(RoomListRoomSummaryProv
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ContentToPreview(data: RoomListRoomSummary) {
|
private fun ContentToPreview(data: RoomListRoomSummary) {
|
||||||
RoomSummaryRow(data)
|
RoomSummaryRow(
|
||||||
|
room = data,
|
||||||
|
onClick = {},
|
||||||
|
onLongClick = {}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
@Immutable
|
@Immutable
|
||||||
data class RoomListRoomSummary(
|
data class RoomListRoomSummary(
|
||||||
val id: String,
|
val id: String,
|
||||||
val roomId: RoomId?,
|
val roomId: RoomId,
|
||||||
val name: String = "",
|
val name: String = "",
|
||||||
val hasUnread: Boolean = false,
|
val hasUnread: Boolean = false,
|
||||||
val timestamp: String? = null,
|
val timestamp: String? = null,
|
||||||
|
|
|
||||||
|
|
@ -17,13 +17,14 @@
|
||||||
package io.element.android.features.roomlist.impl.model
|
package io.element.android.features.roomlist.impl.model
|
||||||
|
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
|
||||||
object RoomListRoomSummaryPlaceholders {
|
object RoomListRoomSummaryPlaceholders {
|
||||||
|
|
||||||
fun create(id: String): RoomListRoomSummary {
|
fun create(id: String): RoomListRoomSummary {
|
||||||
return RoomListRoomSummary(
|
return RoomListRoomSummary(
|
||||||
id = id,
|
id = id,
|
||||||
roomId = null,
|
roomId = RoomId("!aRoom:domain"),
|
||||||
isPlaceholder = true,
|
isPlaceholder = true,
|
||||||
name = "Short name",
|
name = "Short name",
|
||||||
timestamp = "hh:mm",
|
timestamp = "hh:mm",
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ import io.element.android.libraries.ui.strings.R
|
||||||
internal fun RoomListSearchResultView(
|
internal fun RoomListSearchResultView(
|
||||||
state: RoomListState,
|
state: RoomListState,
|
||||||
onRoomClicked: (RoomId) -> Unit,
|
onRoomClicked: (RoomId) -> Unit,
|
||||||
|
onRoomLongClicked: (RoomListRoomSummary) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
|
|
@ -85,7 +86,11 @@ internal fun RoomListSearchResultView(
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
if (state.displaySearchResults) {
|
if (state.displaySearchResults) {
|
||||||
RoomListSearchResultContent(state = state, onRoomClicked = onRoomClicked)
|
RoomListSearchResultContent(
|
||||||
|
state = state,
|
||||||
|
onRoomClicked = onRoomClicked,
|
||||||
|
onRoomLongClicked = onRoomLongClicked,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -96,6 +101,7 @@ internal fun RoomListSearchResultView(
|
||||||
internal fun RoomListSearchResultContent(
|
internal fun RoomListSearchResultContent(
|
||||||
state: RoomListState,
|
state: RoomListState,
|
||||||
onRoomClicked: (RoomId) -> Unit,
|
onRoomClicked: (RoomId) -> Unit,
|
||||||
|
onRoomLongClicked: (RoomListRoomSummary) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val borderColor = MaterialTheme.colorScheme.tertiary
|
val borderColor = MaterialTheme.colorScheme.tertiary
|
||||||
|
|
@ -104,7 +110,6 @@ internal fun RoomListSearchResultContent(
|
||||||
state.eventSink(RoomListEvents.ToggleSearchResults)
|
state.eventSink(RoomListEvents.ToggleSearchResults)
|
||||||
}
|
}
|
||||||
fun onRoomClicked(room: RoomListRoomSummary) {
|
fun onRoomClicked(room: RoomListRoomSummary) {
|
||||||
if (room.roomId == null) return
|
|
||||||
onRoomClicked(room.roomId)
|
onRoomClicked(room.roomId)
|
||||||
}
|
}
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
|
@ -197,7 +202,11 @@ internal fun RoomListSearchResultContent(
|
||||||
items = state.filteredRoomList,
|
items = state.filteredRoomList,
|
||||||
contentType = { room -> room.contentType() },
|
contentType = { room -> room.contentType() },
|
||||||
) { room ->
|
) { room ->
|
||||||
RoomSummaryRow(room = room, onClick = ::onRoomClicked)
|
RoomSummaryRow(
|
||||||
|
room = room,
|
||||||
|
onClick = ::onRoomClicked,
|
||||||
|
onLongClick = onRoomLongClicked,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,11 @@ import app.cash.molecule.RecompositionClock
|
||||||
import app.cash.molecule.moleculeFlow
|
import app.cash.molecule.moleculeFlow
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth
|
import com.google.common.truth.Truth
|
||||||
|
import io.element.android.features.leaveroom.api.LeaveRoomEvent
|
||||||
|
import io.element.android.features.leaveroom.fake.LeaveRoomPresenterFake
|
||||||
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
|
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
|
||||||
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
||||||
|
import io.element.android.features.roomlist.impl.model.aRoomListRoomSummary
|
||||||
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
|
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
|
||||||
import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter
|
import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
|
|
@ -54,6 +57,7 @@ class RoomListPresenterTests {
|
||||||
FakeNetworkMonitor(),
|
FakeNetworkMonitor(),
|
||||||
SnackbarDispatcher(),
|
SnackbarDispatcher(),
|
||||||
FakeInviteDataSource(),
|
FakeInviteDataSource(),
|
||||||
|
LeaveRoomPresenterFake(),
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -82,6 +86,7 @@ class RoomListPresenterTests {
|
||||||
FakeNetworkMonitor(),
|
FakeNetworkMonitor(),
|
||||||
SnackbarDispatcher(),
|
SnackbarDispatcher(),
|
||||||
FakeInviteDataSource(),
|
FakeInviteDataSource(),
|
||||||
|
LeaveRoomPresenterFake(),
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -103,6 +108,7 @@ class RoomListPresenterTests {
|
||||||
FakeNetworkMonitor(),
|
FakeNetworkMonitor(),
|
||||||
SnackbarDispatcher(),
|
SnackbarDispatcher(),
|
||||||
FakeInviteDataSource(),
|
FakeInviteDataSource(),
|
||||||
|
LeaveRoomPresenterFake(),
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -132,6 +138,7 @@ class RoomListPresenterTests {
|
||||||
FakeNetworkMonitor(),
|
FakeNetworkMonitor(),
|
||||||
SnackbarDispatcher(),
|
SnackbarDispatcher(),
|
||||||
FakeInviteDataSource(),
|
FakeInviteDataSource(),
|
||||||
|
LeaveRoomPresenterFake(),
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -164,6 +171,7 @@ class RoomListPresenterTests {
|
||||||
FakeNetworkMonitor(),
|
FakeNetworkMonitor(),
|
||||||
SnackbarDispatcher(),
|
SnackbarDispatcher(),
|
||||||
FakeInviteDataSource(),
|
FakeInviteDataSource(),
|
||||||
|
LeaveRoomPresenterFake(),
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -202,6 +210,7 @@ class RoomListPresenterTests {
|
||||||
FakeNetworkMonitor(),
|
FakeNetworkMonitor(),
|
||||||
SnackbarDispatcher(),
|
SnackbarDispatcher(),
|
||||||
FakeInviteDataSource(),
|
FakeInviteDataSource(),
|
||||||
|
LeaveRoomPresenterFake(),
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -253,6 +262,7 @@ class RoomListPresenterTests {
|
||||||
FakeNetworkMonitor(),
|
FakeNetworkMonitor(),
|
||||||
SnackbarDispatcher(),
|
SnackbarDispatcher(),
|
||||||
FakeInviteDataSource(),
|
FakeInviteDataSource(),
|
||||||
|
LeaveRoomPresenterFake(),
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -278,6 +288,7 @@ class RoomListPresenterTests {
|
||||||
FakeNetworkMonitor(),
|
FakeNetworkMonitor(),
|
||||||
SnackbarDispatcher(),
|
SnackbarDispatcher(),
|
||||||
FakeInviteDataSource(inviteStateFlow),
|
FakeInviteDataSource(inviteStateFlow),
|
||||||
|
LeaveRoomPresenterFake(),
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -297,6 +308,89 @@ class RoomListPresenterTests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - show context menu`() = runTest {
|
||||||
|
val presenter = RoomListPresenter(
|
||||||
|
FakeMatrixClient(A_SESSION_ID),
|
||||||
|
createDateFormatter(),
|
||||||
|
FakeRoomLastMessageFormatter(),
|
||||||
|
FakeSessionVerificationService(),
|
||||||
|
FakeNetworkMonitor(),
|
||||||
|
SnackbarDispatcher(),
|
||||||
|
FakeInviteDataSource(),
|
||||||
|
LeaveRoomPresenterFake(),
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
skipItems(1)
|
||||||
|
|
||||||
|
val initialState = awaitItem()
|
||||||
|
val summary = aRoomListRoomSummary()
|
||||||
|
initialState.eventSink(RoomListEvents.ShowContextMenu(summary))
|
||||||
|
|
||||||
|
val shownState = awaitItem()
|
||||||
|
Truth.assertThat(shownState.contextMenu)
|
||||||
|
.isEqualTo(RoomListState.ContextMenu.Shown(summary.roomId, summary.name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - hide context menu`() = runTest {
|
||||||
|
val presenter = RoomListPresenter(
|
||||||
|
FakeMatrixClient(A_SESSION_ID),
|
||||||
|
createDateFormatter(),
|
||||||
|
FakeRoomLastMessageFormatter(),
|
||||||
|
FakeSessionVerificationService(),
|
||||||
|
FakeNetworkMonitor(),
|
||||||
|
SnackbarDispatcher(),
|
||||||
|
FakeInviteDataSource(),
|
||||||
|
LeaveRoomPresenterFake(),
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
skipItems(1)
|
||||||
|
|
||||||
|
val initialState = awaitItem()
|
||||||
|
val summary = aRoomListRoomSummary()
|
||||||
|
initialState.eventSink(RoomListEvents.ShowContextMenu(summary))
|
||||||
|
|
||||||
|
val shownState = awaitItem()
|
||||||
|
Truth.assertThat(shownState.contextMenu)
|
||||||
|
.isEqualTo(RoomListState.ContextMenu.Shown(summary.roomId, summary.name))
|
||||||
|
shownState.eventSink(RoomListEvents.HideContextMenu)
|
||||||
|
|
||||||
|
val hiddenState = awaitItem()
|
||||||
|
Truth.assertThat(hiddenState.contextMenu).isEqualTo(RoomListState.ContextMenu.Hidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - leave room calls into leave room presenter`() = runTest {
|
||||||
|
val leaveRoomPresenter = LeaveRoomPresenterFake()
|
||||||
|
val presenter = RoomListPresenter(
|
||||||
|
FakeMatrixClient(A_SESSION_ID),
|
||||||
|
createDateFormatter(),
|
||||||
|
FakeRoomLastMessageFormatter(),
|
||||||
|
FakeSessionVerificationService(),
|
||||||
|
FakeNetworkMonitor(),
|
||||||
|
SnackbarDispatcher(),
|
||||||
|
FakeInviteDataSource(),
|
||||||
|
leaveRoomPresenter,
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
skipItems(1)
|
||||||
|
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(RoomListEvents.LeaveRoom(A_ROOM_ID))
|
||||||
|
|
||||||
|
Truth.assertThat(leaveRoomPresenter.events).containsExactly(LeaveRoomEvent.ShowConfirmation(A_ROOM_ID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun createDateFormatter(): LastMessageTimestampFormatter {
|
private fun createDateFormatter(): LastMessageTimestampFormatter {
|
||||||
return FakeLastMessageTimestampFormatter().apply {
|
return FakeLastMessageTimestampFormatter().apply {
|
||||||
givenFormat(A_FORMATTED_DATE)
|
givenFormat(A_FORMATTED_DATE)
|
||||||
|
|
|
||||||
|
|
@ -22,4 +22,5 @@ object VectorIcons {
|
||||||
val Delete = R.drawable.ic_baseline_delete_outline_24
|
val Delete = R.drawable.ic_baseline_delete_outline_24
|
||||||
val Reply = R.drawable.ic_baseline_reply_24
|
val Reply = R.drawable.ic_baseline_reply_24
|
||||||
val Edit = R.drawable.ic_baseline_edit_24
|
val Edit = R.drawable.ic_baseline_edit_24
|
||||||
|
val DoorOpen = R.drawable.ic_door_open_24
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 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 io.element.android.libraries.designsystem.theme.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.BottomSheetDefaults
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.SheetState
|
||||||
|
import androidx.compose.material3.SheetValue
|
||||||
|
import androidx.compose.material3.contentColorFor
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Shape
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewGroup
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ModalBottomSheet(
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
sheetState: SheetState = rememberModalBottomSheetState(),
|
||||||
|
shape: Shape = BottomSheetDefaults.ExpandedShape,
|
||||||
|
containerColor: Color = BottomSheetDefaults.ContainerColor,
|
||||||
|
contentColor: Color = contentColorFor(containerColor),
|
||||||
|
tonalElevation: Dp = BottomSheetDefaults.Elevation,
|
||||||
|
scrimColor: Color = BottomSheetDefaults.ScrimColor,
|
||||||
|
dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
|
||||||
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
androidx.compose.material3.ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
modifier = modifier,
|
||||||
|
sheetState = sheetState,
|
||||||
|
shape = shape,
|
||||||
|
containerColor = containerColor,
|
||||||
|
contentColor = contentColor,
|
||||||
|
tonalElevation = tonalElevation,
|
||||||
|
scrimColor = scrimColor,
|
||||||
|
dragHandle = dragHandle,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// This preview and its screenshots are blank, see: https://issuetracker.google.com/issues/283843380
|
||||||
|
@Preview(group = PreviewGroup.BottomSheets)
|
||||||
|
@Composable
|
||||||
|
internal fun ModalBottomSheetLightPreview() =
|
||||||
|
ElementPreviewLight { ContentToPreview() }
|
||||||
|
|
||||||
|
// This preview and its screenshots are blank, see: https://issuetracker.google.com/issues/283843380
|
||||||
|
@Preview(group = PreviewGroup.BottomSheets)
|
||||||
|
@Composable
|
||||||
|
internal fun ModalBottomSheetDarkPreview() =
|
||||||
|
ElementPreviewDark { ContentToPreview() }
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun ContentToPreview() {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = {},
|
||||||
|
sheetState = SheetState(
|
||||||
|
skipPartiallyExpanded = true,
|
||||||
|
initialValue = SheetValue.Expanded,
|
||||||
|
skipHiddenState = true,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Sheet Content",
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(start = 16.dp, end = 16.dp, bottom = 20.dp)
|
||||||
|
.background(color = Color.Green)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960"
|
||||||
|
android:tint="#000000">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M440,520Q457,520 468.5,508.5Q480,497 480,480Q480,463 468.5,451.5Q457,440 440,440Q423,440 411.5,451.5Q400,463 400,480Q400,497 411.5,508.5Q423,520 440,520ZM280,840L280,760L520,720L520,275Q520,260 511,248Q502,236 488,234L280,200L280,120L500,156Q544,164 572,197Q600,230 600,274L600,786L280,840ZM120,840L120,760L200,760L200,200Q200,166 223.5,143Q247,120 280,120L680,120Q714,120 737,143Q760,166 760,200L760,760L840,760L840,840L120,840ZM280,760L680,760L680,200Q680,200 680,200Q680,200 680,200L280,200Q280,200 280,200Q280,200 280,200L280,760Z"/>
|
||||||
|
</vector>
|
||||||
|
|
@ -57,6 +57,7 @@ dependencies {
|
||||||
implementation(projects.libraries.dateformatter.impl)
|
implementation(projects.libraries.dateformatter.impl)
|
||||||
implementation(projects.features.invitelist.impl)
|
implementation(projects.features.invitelist.impl)
|
||||||
implementation(projects.features.roomlist.impl)
|
implementation(projects.features.roomlist.impl)
|
||||||
|
implementation(projects.features.leaveroom.impl)
|
||||||
implementation(projects.features.login.impl)
|
implementation(projects.features.login.impl)
|
||||||
implementation(projects.features.networkmonitor.impl)
|
implementation(projects.features.networkmonitor.impl)
|
||||||
implementation(libs.coroutines.core)
|
implementation(libs.coroutines.core)
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import io.element.android.features.invitelist.impl.DefaultSeenInvitesStore
|
import io.element.android.features.invitelist.impl.DefaultSeenInvitesStore
|
||||||
|
import io.element.android.features.leaveroom.impl.LeaveRoomPresenterImpl
|
||||||
import io.element.android.features.networkmonitor.impl.NetworkMonitorImpl
|
import io.element.android.features.networkmonitor.impl.NetworkMonitorImpl
|
||||||
import io.element.android.features.roomlist.impl.DefaultInviteStateDataSource
|
import io.element.android.features.roomlist.impl.DefaultInviteStateDataSource
|
||||||
import io.element.android.features.roomlist.impl.DefaultRoomLastMessageFormatter
|
import io.element.android.features.roomlist.impl.DefaultRoomLastMessageFormatter
|
||||||
|
|
@ -33,6 +34,7 @@ import io.element.android.libraries.dateformatter.impl.LocalDateTimeProvider
|
||||||
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.datetime.Clock
|
import kotlinx.datetime.Clock
|
||||||
|
|
@ -57,7 +59,8 @@ class RoomListScreen(
|
||||||
sessionVerificationService = sessionVerificationService,
|
sessionVerificationService = sessionVerificationService,
|
||||||
networkMonitor = NetworkMonitorImpl(context),
|
networkMonitor = NetworkMonitorImpl(context),
|
||||||
snackbarDispatcher = SnackbarDispatcher(),
|
snackbarDispatcher = SnackbarDispatcher(),
|
||||||
inviteStateDataSource = DefaultInviteStateDataSource(matrixClient, DefaultSeenInvitesStore(context), coroutineDispatchers)
|
inviteStateDataSource = DefaultInviteStateDataSource(matrixClient, DefaultSeenInvitesStore(context), coroutineDispatchers),
|
||||||
|
leaveRoomPresenter = LeaveRoomPresenterImpl(matrixClient, RoomMembershipObserver() ,coroutineDispatchers)
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -82,8 +85,13 @@ class RoomListScreen(
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
RoomListView(
|
RoomListView(
|
||||||
state = state,
|
state = state,
|
||||||
modifier = modifier,
|
|
||||||
onRoomClicked = ::onRoomClicked,
|
onRoomClicked = ::onRoomClicked,
|
||||||
|
onSettingsClicked = {},
|
||||||
|
onVerifyClicked = {},
|
||||||
|
onCreateRoomClicked = {},
|
||||||
|
onInvitesClicked = {},
|
||||||
|
onRoomSettingsClicked = {},
|
||||||
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:f20d59b9bb1ea540e7a1f0c1aeefd1439340625429af672bd6cd00f0c3fdc7f6
|
||||||
|
size 5150
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:03ceca5d30354b7736be1bc079053db5ff82d2ac330161c5e07a5d801a24220a
|
||||||
|
size 20341
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:cfe27aff64c8ec1c71b9c41ebd7cef9a59df69275bbb3637cf4a7e0c88bdcb0e
|
||||||
|
size 32997
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:2148b3ffec6f6e953617b035702299f4bb1b5f7f56eacce907f333076da75bd9
|
||||||
|
size 36701
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:7de61d642b9b98c9f0bb8b015b5c57d9645a1552ebed6f0cd215c051c47f0896
|
||||||
|
size 10593
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:59529b30fa2cfef483b365ab888e9327b2dcbae4e40a571642cb534d80379ce2
|
||||||
|
size 18439
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:47e2b24a91c59154221e16b1c9e80689300c077d3f19b8dba4d539754c630e70
|
||||||
|
size 5144
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:0e58c6e0ea9e0df37794d4ac2f8e40ff7995454626618fc0183b5900d62dcfec
|
||||||
|
size 20456
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:71ab2aab2b1aba8baa95c2bd844d5b01410bb3d6004f561038919cf6a7a6b6f5
|
||||||
|
size 32991
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:ceb2bbf886fdd28c8ed2aa755241725de106ae998825e10d3c6608f100987cb1
|
||||||
|
size 36673
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:165597506f493c5adedc840fc22ac0218517bb8ae1c4a7f1fabe43665d0f154f
|
||||||
|
size 10740
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:470b5e47f6b21819aa751736f24f7afd88a5ce3ac911f9d0b6bfbdf94e2f918b
|
||||||
|
size 18597
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:514d015e7edd0c48746439c9e6a929cd24f92c36302d0d746ca71f0482283439
|
||||||
|
size 23123
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:78f9c2e01fd3d0ef827808265308555542e5d24055714c7a632e8baf1baffc8e
|
||||||
|
size 21974
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:3cdb131c68de1fce5a3319151e39148e9f3a71c7bc3984e89ec0a80abf0f7288
|
||||||
|
size 37044
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:a2a261b30866af95b856ee1e7d6ac2cbe2d638cf80277645f361b043d2f94e60
|
||||||
|
size 36658
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:c1c1eedbab868e0c2501220293608572850e052f685f7076ec939b3f1a9abf27
|
||||||
|
size 4464
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b
|
||||||
|
size 4457
|
||||||
Loading…
Add table
Add a link
Reference in a new issue