Invite users to existing rooms (#441)
Invite users to existing rooms Scope: - Allow inviting from the room detail screen and the member list - Invite option is only shown if the user has the correct power level - Search flow the same as creating a new room, allowing multi-select - Existing room members/invitees are disabled with a custom caption - Sending is asynchronous, an error dialog will appear wherever the user is if necessary Closes #245
This commit is contained in:
@@ -56,6 +56,7 @@ dependencies {
|
||||
implementation(projects.tests.uitests)
|
||||
implementation(libs.coil)
|
||||
|
||||
implementation(projects.services.apperror.impl)
|
||||
implementation(projects.services.appnavstate.api)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
|
||||
@@ -17,24 +17,30 @@
|
||||
package io.element.android.appnav.root
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import io.element.android.features.rageshake.api.crash.CrashDetectionPresenter
|
||||
import io.element.android.features.rageshake.api.detection.RageshakeDetectionPresenter
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.services.apperror.api.AppErrorStateService
|
||||
import javax.inject.Inject
|
||||
|
||||
class RootPresenter @Inject constructor(
|
||||
private val crashDetectionPresenter: CrashDetectionPresenter,
|
||||
private val rageshakeDetectionPresenter: RageshakeDetectionPresenter,
|
||||
private val appErrorStateService: AppErrorStateService,
|
||||
) : Presenter<RootState> {
|
||||
|
||||
@Composable
|
||||
override fun present(): RootState {
|
||||
val rageshakeDetectionState = rageshakeDetectionPresenter.present()
|
||||
val crashDetectionState = crashDetectionPresenter.present()
|
||||
val appErrorState by appErrorStateService.appErrorStateFlow.collectAsState()
|
||||
|
||||
return RootState(
|
||||
rageshakeDetectionState = rageshakeDetectionState,
|
||||
crashDetectionState = crashDetectionState,
|
||||
errorState = appErrorState,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,9 +19,11 @@ package io.element.android.appnav.root
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.features.rageshake.api.crash.CrashDetectionState
|
||||
import io.element.android.features.rageshake.api.detection.RageshakeDetectionState
|
||||
import io.element.android.services.apperror.api.AppErrorState
|
||||
|
||||
@Immutable
|
||||
data class RootState(
|
||||
val rageshakeDetectionState: RageshakeDetectionState,
|
||||
val crashDetectionState: CrashDetectionState,
|
||||
val errorState: AppErrorState,
|
||||
)
|
||||
|
||||
@@ -19,6 +19,8 @@ package io.element.android.appnav.root
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.rageshake.api.crash.aCrashDetectionState
|
||||
import io.element.android.features.rageshake.api.detection.aRageshakeDetectionState
|
||||
import io.element.android.services.apperror.api.AppErrorState
|
||||
import io.element.android.services.apperror.api.aAppErrorState
|
||||
|
||||
open class RootStateProvider : PreviewParameterProvider<RootState> {
|
||||
override val values: Sequence<RootState>
|
||||
@@ -30,6 +32,9 @@ open class RootStateProvider : PreviewParameterProvider<RootState> {
|
||||
aRootState().copy(
|
||||
rageshakeDetectionState = aRageshakeDetectionState().copy(showDialog = true),
|
||||
crashDetectionState = aCrashDetectionState().copy(crashDetected = false),
|
||||
),
|
||||
aRootState().copy(
|
||||
errorState = aAppErrorState(),
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -37,4 +42,5 @@ open class RootStateProvider : PreviewParameterProvider<RootState> {
|
||||
fun aRootState() = RootState(
|
||||
rageshakeDetectionState = aRageshakeDetectionState(),
|
||||
crashDetectionState = aCrashDetectionState(),
|
||||
errorState = AppErrorState.NoError,
|
||||
)
|
||||
|
||||
@@ -31,6 +31,7 @@ import io.element.android.features.rageshake.api.detection.RageshakeDetectionVie
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.services.apperror.impl.AppErrorView
|
||||
|
||||
@Composable
|
||||
fun RootView(
|
||||
@@ -60,6 +61,9 @@ fun RootView(
|
||||
state = state.crashDetectionState,
|
||||
onOpenBugReport = ::onOpenBugReport,
|
||||
)
|
||||
AppErrorView(
|
||||
state = state.errorState,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,9 @@ import io.element.android.features.rageshake.test.crash.FakeCrashDataStore
|
||||
import io.element.android.features.rageshake.test.rageshake.FakeRageShake
|
||||
import io.element.android.features.rageshake.test.rageshake.FakeRageshakeDataStore
|
||||
import io.element.android.features.rageshake.test.screenshot.FakeScreenshotHolder
|
||||
import io.element.android.services.apperror.api.AppErrorState
|
||||
import io.element.android.services.apperror.api.AppErrorStateService
|
||||
import io.element.android.services.apperror.impl.DefaultAppErrorStateService
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
@@ -44,7 +47,32 @@ class RootPresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
private fun createPresenter(): RootPresenter {
|
||||
@Test
|
||||
fun `present - passes app error state`() = runTest {
|
||||
val presenter = createPresenter(
|
||||
appErrorService = DefaultAppErrorStateService().apply {
|
||||
showError("Bad news", "Something bad happened")
|
||||
}
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.errorState).isInstanceOf(AppErrorState.Error::class.java)
|
||||
val initialErrorState = initialState.errorState as AppErrorState.Error
|
||||
assertThat(initialErrorState.title).isEqualTo("Bad news")
|
||||
assertThat(initialErrorState.body).isEqualTo("Something bad happened")
|
||||
|
||||
initialErrorState.dismiss()
|
||||
assertThat(awaitItem().errorState).isInstanceOf(AppErrorState.NoError::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createPresenter(
|
||||
appErrorService: AppErrorStateService = DefaultAppErrorStateService()
|
||||
): RootPresenter {
|
||||
val crashDataStore = FakeCrashDataStore()
|
||||
val rageshakeDataStore = FakeRageshakeDataStore()
|
||||
val rageshake = FakeRageShake()
|
||||
@@ -63,6 +91,7 @@ class RootPresenterTest {
|
||||
return RootPresenter(
|
||||
crashDetectionPresenter = crashDetectionPresenter,
|
||||
rageshakeDetectionPresenter = rageshakeDetectionPresenter,
|
||||
appErrorStateService = appErrorService,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
1
changelog.d/245.feature
Normal file
1
changelog.d/245.feature
Normal file
@@ -0,0 +1 @@
|
||||
[Create and join rooms] Add ability to invite users to existing rooms
|
||||
@@ -33,6 +33,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.element.android.features.createroom.impl.R
|
||||
import io.element.android.features.createroom.impl.components.UserListView
|
||||
import io.element.android.features.createroom.impl.userlist.UserListEvents
|
||||
import io.element.android.features.createroom.impl.userlist.UserListState
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
@@ -54,13 +55,17 @@ fun AddPeopleView(
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
if (!state.isSearchActive) {
|
||||
AddPeopleViewTopBar(
|
||||
hasSelectedUsers = state.selectedUsers.isNotEmpty(),
|
||||
onBackPressed = onBackPressed,
|
||||
onNextPressed = onNextPressed,
|
||||
)
|
||||
}
|
||||
AddPeopleViewTopBar(
|
||||
hasSelectedUsers = state.selectedUsers.isNotEmpty(),
|
||||
onBackPressed = {
|
||||
if (state.isSearchActive) {
|
||||
state.eventSink(UserListEvents.OnSearchActiveChanged(false))
|
||||
} else {
|
||||
onBackPressed()
|
||||
}
|
||||
},
|
||||
onNextPressed = onNextPressed,
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
@@ -73,6 +78,7 @@ fun AddPeopleView(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
state = state,
|
||||
showBackButton = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ fun SearchUserBar(
|
||||
active: Boolean,
|
||||
isMultiSelectionEnabled: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
showBackButton: Boolean = true,
|
||||
placeHolderTitle: String = stringResource(R.string.common_search_for_someone),
|
||||
onActiveChanged: (Boolean) -> Unit = {},
|
||||
onTextChanged: (String) -> Unit = {},
|
||||
@@ -52,6 +53,7 @@ fun SearchUserBar(
|
||||
onActiveChange = onActiveChanged,
|
||||
modifier = modifier,
|
||||
placeHolderTitle = placeHolderTitle,
|
||||
showBackButton = showBackButton,
|
||||
contentPrefix = {
|
||||
if (isMultiSelectionEnabled && active && selectedUsers.isNotEmpty()) {
|
||||
SelectedUsersList(
|
||||
|
||||
@@ -36,6 +36,7 @@ import io.element.android.libraries.matrix.ui.components.SelectedUsersList
|
||||
fun UserListView(
|
||||
state: UserListState,
|
||||
modifier: Modifier = Modifier,
|
||||
showBackButton: Boolean = true,
|
||||
onUserSelected: (MatrixUser) -> Unit = {},
|
||||
onUserDeselected: (MatrixUser) -> Unit = {},
|
||||
) {
|
||||
@@ -49,6 +50,7 @@ fun UserListView(
|
||||
selectedUsers = state.selectedUsers,
|
||||
active = state.isSearchActive,
|
||||
isMultiSelectionEnabled = state.isMultiSelectionEnabled,
|
||||
showBackButton = showBackButton,
|
||||
onActiveChanged = { state.eventSink(UserListEvents.OnSearchActiveChanged(it)) },
|
||||
onTextChanged = { state.eventSink(UserListEvents.UpdateSearchQuery(it)) },
|
||||
onUserSelected = {
|
||||
|
||||
@@ -43,6 +43,7 @@ dependencies {
|
||||
implementation(projects.libraries.androidutils)
|
||||
api(projects.features.roomdetails.api)
|
||||
api(projects.libraries.usersearch.api)
|
||||
api(projects.services.apperror.api)
|
||||
implementation(libs.coil.compose)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
@@ -51,6 +52,7 @@ dependencies {
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.libraries.usersearch.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
|
||||
ksp(libs.showkase.processor)
|
||||
|
||||
@@ -28,6 +28,7 @@ import com.bumble.appyx.navmodel.backstack.operation.push
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.features.roomdetails.impl.invite.RoomInviteMembersNode
|
||||
import io.element.android.features.roomdetails.impl.members.RoomMemberListNode
|
||||
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsNode
|
||||
import io.element.android.libraries.architecture.BackstackNode
|
||||
@@ -57,6 +58,9 @@ class RoomDetailsFlowNode @AssistedInject constructor(
|
||||
@Parcelize
|
||||
object RoomMemberList : NavTarget
|
||||
|
||||
@Parcelize
|
||||
object InviteMembers : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class RoomMemberDetails(val roomMemberId: UserId) : NavTarget
|
||||
}
|
||||
@@ -68,6 +72,10 @@ class RoomDetailsFlowNode @AssistedInject constructor(
|
||||
override fun openRoomMemberList() {
|
||||
backstack.push(NavTarget.RoomMemberList)
|
||||
}
|
||||
|
||||
override fun openInviteMembers() {
|
||||
backstack.push(NavTarget.InviteMembers)
|
||||
}
|
||||
}
|
||||
createNode<RoomDetailsNode>(buildContext, listOf(roomDetailsCallback))
|
||||
}
|
||||
@@ -76,9 +84,16 @@ class RoomDetailsFlowNode @AssistedInject constructor(
|
||||
override fun openRoomMemberDetails(roomMemberId: UserId) {
|
||||
backstack.push(NavTarget.RoomMemberDetails(roomMemberId))
|
||||
}
|
||||
|
||||
override fun openInviteMembers() {
|
||||
backstack.push(NavTarget.InviteMembers)
|
||||
}
|
||||
}
|
||||
createNode<RoomMemberListNode>(buildContext, listOf(roomMemberListCallback))
|
||||
}
|
||||
NavTarget.InviteMembers -> {
|
||||
createNode<RoomInviteMembersNode>(buildContext)
|
||||
}
|
||||
is NavTarget.RoomMemberDetails -> {
|
||||
createNode<RoomMemberDetailsNode>(buildContext, listOf(RoomMemberDetailsNode.Inputs(navTarget.roomMemberId)))
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ class RoomDetailsNode @AssistedInject constructor(
|
||||
|
||||
interface Callback : Plugin {
|
||||
fun openRoomMemberList()
|
||||
fun openInviteMembers()
|
||||
}
|
||||
|
||||
private val callbacks = plugins<Callback>()
|
||||
@@ -53,6 +54,10 @@ class RoomDetailsNode @AssistedInject constructor(
|
||||
callbacks.forEach { it.openRoomMemberList() }
|
||||
}
|
||||
|
||||
private fun invitePeople() {
|
||||
callbacks.forEach { it.openInviteMembers() }
|
||||
}
|
||||
|
||||
private fun onShareRoom(context: Context) {
|
||||
val alias = room.alias ?: room.alternativeAliases.firstOrNull()
|
||||
val permalinkResult = alias?.let { PermalinkBuilder.permalinkForRoomAlias(it) }
|
||||
@@ -105,6 +110,7 @@ class RoomDetailsNode @AssistedInject constructor(
|
||||
onShareRoom = ::onShareRoom,
|
||||
onShareMember = ::onShareMember,
|
||||
openRoomMemberList = ::openRoomMemberList,
|
||||
invitePeople = ::invitePeople,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ class RoomDetailsPresenter @Inject constructor(
|
||||
|
||||
val membersState by room.membersStateFlow.collectAsState()
|
||||
val memberCount by getMemberCount(membersState)
|
||||
val canInvite by getCanInvite(membersState)
|
||||
val dmMember by room.getDirectRoomMember(membersState)
|
||||
val roomMemberDetailsPresenter = roomMemberDetailsPresenter(dmMember)
|
||||
val roomType = getRoomType(dmMember)
|
||||
@@ -76,7 +77,7 @@ class RoomDetailsPresenter @Inject constructor(
|
||||
error = error,
|
||||
)
|
||||
}
|
||||
is RoomDetailsEvent.ClearLeaveRoomWarning -> leaveRoomWarning.value = null
|
||||
RoomDetailsEvent.ClearLeaveRoomWarning -> leaveRoomWarning.value = null
|
||||
RoomDetailsEvent.ClearError -> error.value = null
|
||||
}
|
||||
}
|
||||
@@ -91,6 +92,7 @@ class RoomDetailsPresenter @Inject constructor(
|
||||
roomTopic = room.topic,
|
||||
memberCount = memberCount,
|
||||
isEncrypted = room.isEncrypted,
|
||||
canInvite = canInvite,
|
||||
displayLeaveRoomWarning = leaveRoomWarning.value,
|
||||
error = error.value,
|
||||
roomType = roomType.value,
|
||||
@@ -117,6 +119,15 @@ class RoomDetailsPresenter @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun getCanInvite(membersState: MatrixRoomMembersState): State<Boolean> {
|
||||
val canInvite = remember(membersState) { mutableStateOf(false) }
|
||||
LaunchedEffect(membersState) {
|
||||
canInvite.value = room.canInvite().getOrElse { false }
|
||||
}
|
||||
return canInvite
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun getMemberCount(membersState: MatrixRoomMembersState): State<Async<Int>> {
|
||||
return remember(membersState) {
|
||||
|
||||
@@ -32,6 +32,7 @@ data class RoomDetailsState(
|
||||
val error: RoomDetailsError?,
|
||||
val roomType: RoomDetailsType,
|
||||
val roomMemberDetailsState: RoomMemberDetailsState?,
|
||||
val canInvite: Boolean,
|
||||
val eventSink: (RoomDetailsEvent) -> Unit
|
||||
)
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ open class RoomDetailsStateProvider : PreviewParameterProvider<RoomDetailsState>
|
||||
aRoomDetailsState().copy(memberCount = Async.Failure(Throwable())),
|
||||
aDmRoomDetailsState().copy(roomName = "Daniel"),
|
||||
aDmRoomDetailsState(isDmMemberIgnored = true).copy(roomName = "Daniel"),
|
||||
aRoomDetailsState().copy(canInvite = true),
|
||||
// Add other state here
|
||||
)
|
||||
}
|
||||
@@ -71,6 +72,7 @@ fun aRoomDetailsState() = RoomDetailsState(
|
||||
isEncrypted = true,
|
||||
displayLeaveRoomWarning = null,
|
||||
error = null,
|
||||
canInvite = false,
|
||||
roomType = RoomDetailsType.Room,
|
||||
roomMemberDetailsState = null,
|
||||
eventSink = {}
|
||||
|
||||
@@ -78,6 +78,7 @@ fun RoomDetailsView(
|
||||
onShareRoom: () -> Unit,
|
||||
onShareMember: (RoomMember) -> Unit,
|
||||
openRoomMemberList: () -> Unit,
|
||||
invitePeople: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
|
||||
@@ -127,7 +128,9 @@ fun RoomDetailsView(
|
||||
MembersSection(
|
||||
memberCount = memberCount,
|
||||
isLoading = state.memberCount.isLoading(),
|
||||
openRoomMemberList = openRoomMemberList
|
||||
showInvite = state.canInvite,
|
||||
openRoomMemberList = openRoomMemberList,
|
||||
invitePeople = invitePeople,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -211,8 +214,10 @@ internal fun TopicSection(roomTopic: String, modifier: Modifier = Modifier) {
|
||||
internal fun MembersSection(
|
||||
memberCount: Int?,
|
||||
isLoading: Boolean,
|
||||
showInvite: Boolean,
|
||||
invitePeople: () -> Unit,
|
||||
openRoomMemberList: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
openRoomMemberList: () -> Unit
|
||||
) {
|
||||
PreferenceCategory(modifier = modifier) {
|
||||
PreferenceText(
|
||||
@@ -222,10 +227,13 @@ internal fun MembersSection(
|
||||
onClick = openRoomMemberList,
|
||||
loadingCurrentValue = isLoading,
|
||||
)
|
||||
PreferenceText(
|
||||
title = stringResource(R.string.screen_room_details_invite_people_title),
|
||||
icon = Icons.Outlined.PersonAddAlt,
|
||||
)
|
||||
if (showInvite) {
|
||||
PreferenceText(
|
||||
title = stringResource(R.string.screen_room_details_invite_people_title),
|
||||
icon = Icons.Outlined.PersonAddAlt,
|
||||
onClick = invitePeople,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,5 +299,6 @@ private fun ContentToPreview(state: RoomDetailsState) {
|
||||
onShareRoom = {},
|
||||
onShareMember = {},
|
||||
openRoomMemberList = {},
|
||||
invitePeople = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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.roomdetails.impl.invite
|
||||
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
|
||||
sealed interface RoomInviteMembersEvents {
|
||||
data class ToggleUser(val user: MatrixUser) : RoomInviteMembersEvents
|
||||
data class UpdateSearchQuery(val query: String) : RoomInviteMembersEvents
|
||||
data class OnSearchActiveChanged(val active: Boolean) : RoomInviteMembersEvents
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* 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.roomdetails.impl.invite
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.services.apperror.api.AppErrorStateService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import io.element.android.libraries.ui.strings.R as StringR
|
||||
|
||||
@ContributesNode(RoomScope::class)
|
||||
class RoomInviteMembersNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
coroutineDispatchers: CoroutineDispatchers,
|
||||
private val room: MatrixRoom,
|
||||
private val presenter: RoomInviteMembersPresenter,
|
||||
private val appErrorStateService: AppErrorStateService,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
|
||||
private val coroutineScope = CoroutineScope(SupervisorJob() + coroutineDispatchers.io)
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state = presenter.present()
|
||||
val context = LocalContext.current.applicationContext
|
||||
|
||||
RoomInviteMembersView(
|
||||
state = state,
|
||||
modifier = modifier,
|
||||
onBackPressed = { navigateUp() },
|
||||
onSendPressed = { users ->
|
||||
navigateUp()
|
||||
|
||||
coroutineScope.launch {
|
||||
val anyInviteFailed = users
|
||||
.map { room.inviteUserById(it.userId) }
|
||||
.any { it.isFailure }
|
||||
|
||||
if (anyInviteFailed) {
|
||||
appErrorStateService.showError(
|
||||
title = context.getString(StringR.string.common_unable_to_invite_title),
|
||||
body = context.getString(StringR.string.common_unable_to_invite_message),
|
||||
)
|
||||
}
|
||||
|
||||
room.updateMembers()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* 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.roomdetails.impl.invite
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import io.element.android.features.roomdetails.impl.members.RoomMemberListDataSource
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.architecture.execute
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.usersearch.api.UserRepository
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
class RoomInviteMembersPresenter @Inject constructor(
|
||||
private val userRepository: UserRepository,
|
||||
private val roomMemberListDataSource: RoomMemberListDataSource,
|
||||
private val coroutineDispatchers: CoroutineDispatchers,
|
||||
) : Presenter<RoomInviteMembersState> {
|
||||
|
||||
@Composable
|
||||
override fun present(): RoomInviteMembersState {
|
||||
val roomMembers = remember { mutableStateOf<Async<ImmutableList<RoomMember>>>(Async.Loading()) }
|
||||
val selectedUsers = remember { mutableStateOf<ImmutableList<MatrixUser>>(persistentListOf()) }
|
||||
val searchResults = remember { mutableStateOf<SearchBarResultState<ImmutableList<InvitableUser>>>(SearchBarResultState.NotSearching()) }
|
||||
var searchQuery by rememberSaveable { mutableStateOf("") }
|
||||
var searchActive by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
fetchMembers(roomMembers)
|
||||
}
|
||||
|
||||
LaunchedEffect(searchQuery, roomMembers) {
|
||||
performSearch(searchResults, roomMembers, selectedUsers, searchQuery)
|
||||
}
|
||||
|
||||
return RoomInviteMembersState(
|
||||
canInvite = selectedUsers.value.isNotEmpty(),
|
||||
selectedUsers = selectedUsers.value,
|
||||
searchQuery = searchQuery,
|
||||
isSearchActive = searchActive,
|
||||
searchResults = searchResults.value,
|
||||
eventSink = {
|
||||
when (it) {
|
||||
is RoomInviteMembersEvents.OnSearchActiveChanged -> {
|
||||
searchActive = it.active
|
||||
searchQuery = ""
|
||||
}
|
||||
|
||||
is RoomInviteMembersEvents.UpdateSearchQuery -> {
|
||||
searchQuery = it.query
|
||||
}
|
||||
|
||||
is RoomInviteMembersEvents.ToggleUser -> {
|
||||
selectedUsers.toggleUser(it.user)
|
||||
searchResults.toggleUser(it.user)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@JvmName("toggleUserInSelectedUsers")
|
||||
private fun MutableState<ImmutableList<MatrixUser>>.toggleUser(user: MatrixUser) {
|
||||
value = if (value.contains(user)) {
|
||||
value.filterNot { it == user }
|
||||
} else {
|
||||
(value + user)
|
||||
}.toImmutableList()
|
||||
}
|
||||
|
||||
@JvmName("toggleUserInSearchResults")
|
||||
private fun MutableState<SearchBarResultState<ImmutableList<InvitableUser>>>.toggleUser(user: MatrixUser) {
|
||||
val existingResults = value
|
||||
if (existingResults is SearchBarResultState.Results) {
|
||||
value = SearchBarResultState.Results(
|
||||
existingResults.results.map { iu ->
|
||||
if (iu.matrixUser == user) {
|
||||
iu.copy(isSelected = !iu.isSelected)
|
||||
} else {
|
||||
iu
|
||||
}
|
||||
}.toImmutableList()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun performSearch(
|
||||
searchResults: MutableState<SearchBarResultState<ImmutableList<InvitableUser>>>,
|
||||
roomMembers: MutableState<Async<ImmutableList<RoomMember>>>,
|
||||
selectedUsers: MutableState<ImmutableList<MatrixUser>>,
|
||||
searchQuery: String,
|
||||
) = withContext(coroutineDispatchers.io) {
|
||||
searchResults.value = SearchBarResultState.NotSearching()
|
||||
|
||||
val joinedMembers = roomMembers.value.dataOrNull().orEmpty()
|
||||
|
||||
userRepository.search(searchQuery).collect {
|
||||
searchResults.value = when {
|
||||
it.isEmpty() -> SearchBarResultState.NoResults()
|
||||
else -> SearchBarResultState.Results(it.map { user ->
|
||||
val existingMembership = joinedMembers.firstOrNull { j -> j.userId == user.userId }?.membership
|
||||
val isJoined = existingMembership == RoomMembershipState.JOIN
|
||||
val isInvited = existingMembership == RoomMembershipState.INVITE
|
||||
InvitableUser(
|
||||
matrixUser = user,
|
||||
isSelected = selectedUsers.value.contains(user) || isJoined || isInvited,
|
||||
isAlreadyJoined = isJoined,
|
||||
isAlreadyInvited = isInvited,
|
||||
)
|
||||
}.toImmutableList())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchMembers(roomMembers: MutableState<Async<ImmutableList<RoomMember>>>) {
|
||||
suspend {
|
||||
withContext(coroutineDispatchers.io) {
|
||||
roomMemberListDataSource.search("").toImmutableList()
|
||||
}
|
||||
}.execute(roomMembers)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* 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.roomdetails.impl.invite
|
||||
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
data class RoomInviteMembersState(
|
||||
val canInvite: Boolean = false,
|
||||
val searchQuery: String = "",
|
||||
val searchResults: SearchBarResultState<ImmutableList<InvitableUser>> = SearchBarResultState.NotSearching(),
|
||||
val selectedUsers: ImmutableList<MatrixUser> = persistentListOf(),
|
||||
val isSearchActive: Boolean = false,
|
||||
val eventSink: (RoomInviteMembersEvents) -> Unit = {},
|
||||
)
|
||||
|
||||
data class InvitableUser(
|
||||
val matrixUser: MatrixUser,
|
||||
val isSelected: Boolean = false,
|
||||
val isAlreadyJoined: Boolean = false,
|
||||
val isAlreadyInvited: Boolean = false,
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* 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.roomdetails.impl.invite
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
internal class RoomInviteMembersStateProvider : PreviewParameterProvider<RoomInviteMembersState> {
|
||||
override val values: Sequence<RoomInviteMembersState>
|
||||
get() = sequenceOf(
|
||||
RoomInviteMembersState(),
|
||||
RoomInviteMembersState(canInvite = true, selectedUsers = aMatrixUserList().toImmutableList()),
|
||||
RoomInviteMembersState(isSearchActive = true, searchQuery = "some query"),
|
||||
RoomInviteMembersState(isSearchActive = true, searchQuery = "some query", selectedUsers = aMatrixUserList().toImmutableList()),
|
||||
RoomInviteMembersState(isSearchActive = true, searchQuery = "some query", searchResults = SearchBarResultState.NoResults()),
|
||||
RoomInviteMembersState(
|
||||
isSearchActive = true,
|
||||
canInvite = true,
|
||||
searchQuery = "some query",
|
||||
selectedUsers = persistentListOf(
|
||||
aMatrixUser("@carol:server.org", "Carol")
|
||||
),
|
||||
searchResults = SearchBarResultState.Results(
|
||||
persistentListOf(
|
||||
InvitableUser(aMatrixUser("@alice:server.org")),
|
||||
InvitableUser(aMatrixUser("@bob:server.org", "Bob")),
|
||||
InvitableUser(aMatrixUser("@carol:server.org", "Carol"), isSelected = true),
|
||||
InvitableUser(aMatrixUser("@eve:server.org", "Eve"), isSelected = true, isAlreadyJoined = true),
|
||||
InvitableUser(aMatrixUser("@justin:server.org", "Justin"), isSelected = true, isAlreadyInvited = true),
|
||||
)
|
||||
)
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/*
|
||||
* 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.roomdetails.impl.invite
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
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.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.element.android.features.roomdetails.impl.R
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
import io.element.android.libraries.designsystem.theme.components.CenterAlignedTopAppBar
|
||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBar
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.ui.components.CheckableUserRow
|
||||
import io.element.android.libraries.matrix.ui.components.SelectedUsersList
|
||||
import io.element.android.libraries.matrix.ui.model.getAvatarData
|
||||
import io.element.android.libraries.matrix.ui.model.getBestName
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import io.element.android.libraries.ui.strings.R as StringR
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun RoomInviteMembersView(
|
||||
state: RoomInviteMembersState,
|
||||
modifier: Modifier = Modifier,
|
||||
onBackPressed: () -> Unit = {},
|
||||
onSendPressed: (List<MatrixUser>) -> Unit = {},
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
RoomInviteMembersTopBar(
|
||||
onBackPressed = {
|
||||
if (state.isSearchActive) {
|
||||
state.eventSink(RoomInviteMembersEvents.OnSearchActiveChanged(false))
|
||||
} else {
|
||||
onBackPressed()
|
||||
}
|
||||
},
|
||||
onSendPressed = { onSendPressed(state.selectedUsers) },
|
||||
canSend = state.canInvite,
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(padding)
|
||||
.consumeWindowInsets(padding),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
RoomInviteMembersSearchBar(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
query = state.searchQuery,
|
||||
selectedUsers = state.selectedUsers,
|
||||
state = state.searchResults,
|
||||
active = state.isSearchActive,
|
||||
onActiveChanged = { state.eventSink(RoomInviteMembersEvents.OnSearchActiveChanged(it)) },
|
||||
onTextChanged = { state.eventSink(RoomInviteMembersEvents.UpdateSearchQuery(it)) },
|
||||
onUserToggled = { state.eventSink(RoomInviteMembersEvents.ToggleUser(it)) },
|
||||
)
|
||||
|
||||
if (!state.isSearchActive) {
|
||||
SelectedUsersList(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
selectedUsers = state.selectedUsers,
|
||||
autoScroll = true,
|
||||
onUserRemoved = { state.eventSink(RoomInviteMembersEvents.ToggleUser(it)) },
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun RoomInviteMembersTopBar(
|
||||
canSend: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onBackPressed: () -> Unit = {},
|
||||
onSendPressed: () -> Unit = {},
|
||||
) {
|
||||
CenterAlignedTopAppBar(
|
||||
modifier = modifier,
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.screen_room_details_invite_people_title),
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
},
|
||||
navigationIcon = { BackButton(onClick = onBackPressed) },
|
||||
actions = {
|
||||
TextButton(
|
||||
onClick = onSendPressed,
|
||||
content = {
|
||||
Text(stringResource(StringR.string.action_send))
|
||||
},
|
||||
enabled = canSend,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RoomInviteMembersSearchBar(
|
||||
query: String,
|
||||
state: SearchBarResultState<ImmutableList<InvitableUser>>,
|
||||
selectedUsers: ImmutableList<MatrixUser>,
|
||||
active: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
placeHolderTitle: String = stringResource(io.element.android.libraries.ui.strings.R.string.common_search_for_someone),
|
||||
onActiveChanged: (Boolean) -> Unit = {},
|
||||
onTextChanged: (String) -> Unit = {},
|
||||
onUserToggled: (MatrixUser) -> Unit = {},
|
||||
) {
|
||||
SearchBar(
|
||||
query = query,
|
||||
onQueryChange = onTextChanged,
|
||||
active = active,
|
||||
onActiveChange = onActiveChanged,
|
||||
modifier = modifier,
|
||||
placeHolderTitle = placeHolderTitle,
|
||||
contentPrefix = {
|
||||
if (selectedUsers.isNotEmpty()) {
|
||||
SelectedUsersList(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
selectedUsers = selectedUsers,
|
||||
autoScroll = true,
|
||||
onUserRemoved = onUserToggled,
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
)
|
||||
}
|
||||
},
|
||||
showBackButton = false,
|
||||
resultState = state,
|
||||
resultHandler = { results ->
|
||||
Text(
|
||||
text = "Search results",
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, top = 12.dp, end = 16.dp, bottom = 8.dp)
|
||||
)
|
||||
|
||||
LazyColumn {
|
||||
items(results) { invitableUser ->
|
||||
CheckableUserRow(
|
||||
checked = invitableUser.isSelected,
|
||||
enabled = !invitableUser.isAlreadyInvited && !invitableUser.isAlreadyJoined,
|
||||
avatarData = invitableUser.matrixUser.getAvatarData(AvatarSize.MEDIUM),
|
||||
name = invitableUser.matrixUser.getBestName(),
|
||||
subtext = when {
|
||||
// If they're already invited or joined we show that information
|
||||
invitableUser.isAlreadyJoined -> stringResource(R.string.screen_room_details_already_a_member)
|
||||
invitableUser.isAlreadyInvited -> stringResource(R.string.screen_room_details_already_invited)
|
||||
// Otherwise show the ID, unless that's already used for their name
|
||||
invitableUser.matrixUser.displayName.isNullOrEmpty().not() -> invitableUser.matrixUser.userId.value
|
||||
else -> null
|
||||
},
|
||||
onCheckedChange = { onUserToggled(invitableUser.matrixUser) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun RoomInviteMembersLightPreview(@PreviewParameter(RoomInviteMembersStateProvider::class) state: RoomInviteMembersState) =
|
||||
ElementPreviewLight { ContentToPreview(state) }
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun RoomInviteMembersDarkPreview(@PreviewParameter(RoomInviteMembersStateProvider::class) state: RoomInviteMembersState) =
|
||||
ElementPreviewDark { ContentToPreview(state) }
|
||||
|
||||
@Composable
|
||||
private fun ContentToPreview(state: RoomInviteMembersState) {
|
||||
RoomInviteMembersView(state)
|
||||
}
|
||||
@@ -27,7 +27,6 @@ import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
|
||||
@ContributesNode(RoomScope::class)
|
||||
class RoomMemberListNode @AssistedInject constructor(
|
||||
@@ -38,6 +37,7 @@ class RoomMemberListNode @AssistedInject constructor(
|
||||
|
||||
interface Callback : Plugin {
|
||||
fun openRoomMemberDetails(roomMemberId: UserId)
|
||||
fun openInviteMembers()
|
||||
}
|
||||
|
||||
private val callbacks = plugins<Callback>()
|
||||
@@ -48,6 +48,12 @@ class RoomMemberListNode @AssistedInject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun openInviteMembers() {
|
||||
callbacks.forEach {
|
||||
it.openInviteMembers()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state = presenter.present()
|
||||
@@ -56,6 +62,7 @@ class RoomMemberListNode @AssistedInject constructor(
|
||||
modifier = modifier,
|
||||
onBackPressed = { navigateUp() },
|
||||
onMemberSelected = this::openRoomMemberDetails,
|
||||
onInvitePressed = this::openInviteMembers,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ package io.element.android.features.roomdetails.impl.members
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -27,12 +29,15 @@ import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
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.RoomMembershipState
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
class RoomMemberListPresenter @Inject constructor(
|
||||
private val room: MatrixRoom,
|
||||
private val roomMemberListDataSource: RoomMemberListDataSource,
|
||||
private val coroutineDispatchers: CoroutineDispatchers,
|
||||
) : Presenter<RoomMemberListState> {
|
||||
@@ -46,6 +51,9 @@ class RoomMemberListPresenter @Inject constructor(
|
||||
}
|
||||
var isSearchActive by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val membersState by room.membersStateFlow.collectAsState()
|
||||
val canInvite by getCanInvite(membersState = membersState)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
withContext(coroutineDispatchers.io) {
|
||||
val members = roomMemberListDataSource.search("").groupBy { it.membership }
|
||||
@@ -80,6 +88,7 @@ class RoomMemberListPresenter @Inject constructor(
|
||||
searchQuery = searchQuery,
|
||||
searchResults = searchResults,
|
||||
isSearchActive = isSearchActive,
|
||||
canInvite = canInvite,
|
||||
eventSink = { event ->
|
||||
when (event) {
|
||||
is RoomMemberListEvents.OnSearchActiveChanged -> isSearchActive = event.active
|
||||
@@ -88,5 +97,14 @@ class RoomMemberListPresenter @Inject constructor(
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun getCanInvite(membersState: MatrixRoomMembersState): State<Boolean> {
|
||||
val canInvite = remember(membersState) { mutableStateOf(false) }
|
||||
LaunchedEffect(membersState) {
|
||||
canInvite.value = room.canInvite().getOrElse { false }
|
||||
}
|
||||
return canInvite
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ data class RoomMemberListState(
|
||||
val searchQuery: String,
|
||||
val searchResults: SearchBarResultState<RoomMembers>,
|
||||
val isSearchActive: Boolean,
|
||||
val canInvite: Boolean,
|
||||
val eventSink: (RoomMemberListEvents) -> Unit,
|
||||
)
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ internal class RoomMemberListStateProvider : PreviewParameterProvider<RoomMember
|
||||
)
|
||||
),
|
||||
aRoomMemberListState(roomMembers = Async.Loading()),
|
||||
aRoomMemberListState().copy(canInvite = true),
|
||||
aRoomMemberListState().copy(isSearchActive = false),
|
||||
aRoomMemberListState().copy(isSearchActive = true),
|
||||
aRoomMemberListState().copy(isSearchActive = true, searchQuery = "someone"),
|
||||
@@ -65,6 +66,7 @@ internal fun aRoomMemberListState(
|
||||
searchQuery = "",
|
||||
searchResults = searchResults,
|
||||
isSearchActive = false,
|
||||
canInvite = false,
|
||||
eventSink = {}
|
||||
)
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBar
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
@@ -68,6 +69,7 @@ import io.element.android.libraries.ui.strings.R as StringR
|
||||
fun RoomMemberListView(
|
||||
state: RoomMemberListState,
|
||||
onBackPressed: () -> Unit,
|
||||
onInvitePressed: () -> Unit,
|
||||
onMemberSelected: (UserId) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -79,7 +81,11 @@ fun RoomMemberListView(
|
||||
Scaffold(
|
||||
topBar = {
|
||||
if (!state.isSearchActive) {
|
||||
RoomMemberListTopBar(onBackPressed = onBackPressed)
|
||||
RoomMemberListTopBar(
|
||||
canInvite = state.canInvite,
|
||||
onBackPressed = onBackPressed,
|
||||
onInvitePressed = onInvitePressed,
|
||||
)
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
@@ -192,8 +198,10 @@ private fun RoomMemberListItem(
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun RoomMemberListTopBar(
|
||||
canInvite: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onBackPressed: () -> Unit = {},
|
||||
onInvitePressed: () -> Unit = {},
|
||||
) {
|
||||
CenterAlignedTopAppBar(
|
||||
modifier = modifier,
|
||||
@@ -205,6 +213,19 @@ private fun RoomMemberListTopBar(
|
||||
)
|
||||
},
|
||||
navigationIcon = { BackButton(onClick = onBackPressed) },
|
||||
actions = {
|
||||
if (canInvite) {
|
||||
TextButton(
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
onClick = onInvitePressed,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(StringR.string.action_invite),
|
||||
fontSize = 16.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -252,6 +273,7 @@ private fun ContentToPreview(state: RoomMemberListState) {
|
||||
RoomMemberListView(
|
||||
state = state,
|
||||
onBackPressed = {},
|
||||
onMemberSelected = {}
|
||||
onMemberSelected = {},
|
||||
onInvitePressed = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ import io.element.android.features.roomdetails.impl.RoomDetailsEvent
|
||||
import io.element.android.features.roomdetails.impl.RoomDetailsPresenter
|
||||
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.aRoomMemberList
|
||||
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
@@ -33,7 +32,6 @@ import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
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.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
|
||||
@@ -101,18 +99,19 @@ class RoomDetailsPresenterTests {
|
||||
room.givenRoomMembersState(MatrixRoomMembersState.Unknown)
|
||||
val initialState = awaitItem()
|
||||
Truth.assertThat(initialState.memberCount).isEqualTo(Async.Uninitialized)
|
||||
skipItems(1)
|
||||
|
||||
room.givenRoomMembersState(MatrixRoomMembersState.Pending(null))
|
||||
val loadingState = awaitItem()
|
||||
Truth.assertThat(loadingState.memberCount).isEqualTo(Async.Loading(null))
|
||||
|
||||
room.givenRoomMembersState(MatrixRoomMembersState.Error(error))
|
||||
//skipItems(1)
|
||||
skipItems(1)
|
||||
val failureState = awaitItem()
|
||||
Truth.assertThat(failureState.memberCount).isEqualTo(Async.Failure(error, null))
|
||||
|
||||
room.givenRoomMembersState(MatrixRoomMembersState.Ready(roomMembers))
|
||||
//skipItems(1)
|
||||
skipItems(1)
|
||||
val successState = awaitItem()
|
||||
Truth.assertThat(successState.memberCount).isEqualTo(Async.Success(1))
|
||||
|
||||
@@ -166,6 +165,8 @@ class RoomDetailsPresenterTests {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
skipItems(1)
|
||||
|
||||
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true))
|
||||
val confirmationState = awaitItem()
|
||||
Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.PrivateRoom)
|
||||
@@ -182,6 +183,8 @@ class RoomDetailsPresenterTests {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
skipItems(1)
|
||||
|
||||
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true))
|
||||
val confirmationState = awaitItem()
|
||||
Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.LastUserInRoom)
|
||||
@@ -198,6 +201,8 @@ class RoomDetailsPresenterTests {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
skipItems(1)
|
||||
|
||||
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true))
|
||||
val confirmationState = awaitItem()
|
||||
Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.Generic)
|
||||
@@ -214,6 +219,8 @@ class RoomDetailsPresenterTests {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
skipItems(1)
|
||||
|
||||
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = false))
|
||||
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
@@ -235,6 +242,8 @@ class RoomDetailsPresenterTests {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
skipItems(1)
|
||||
|
||||
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = false))
|
||||
val errorState = awaitItem()
|
||||
Truth.assertThat(errorState.error).isNotNull()
|
||||
@@ -242,6 +251,50 @@ class RoomDetailsPresenterTests {
|
||||
Truth.assertThat(awaitItem().error).isNull()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - initial state when user can invite others to room`() = runTest {
|
||||
val room = aMatrixRoom().apply {
|
||||
givenCanInviteResult(Result.success(true))
|
||||
}
|
||||
val presenter = aRoomDetailsPresenter(room)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
// Initially false
|
||||
Truth.assertThat(awaitItem().canInvite).isFalse()
|
||||
// Then the asynchronous check completes and it becomes true
|
||||
Truth.assertThat(awaitItem().canInvite).isTrue()
|
||||
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - initial state when user can not invite others to room`() = runTest {
|
||||
val room = aMatrixRoom().apply {
|
||||
givenCanInviteResult(Result.success(false))
|
||||
}
|
||||
val presenter = aRoomDetailsPresenter(room)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
Truth.assertThat(awaitItem().canInvite).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - initial state when canInvite errors`() = runTest {
|
||||
val room = aMatrixRoom().apply {
|
||||
givenCanInviteResult(Result.failure(Throwable("Whoops")))
|
||||
}
|
||||
val presenter = aRoomDetailsPresenter(room)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
Truth.assertThat(awaitItem().canInvite).isFalse()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun aMatrixClient(
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
/*
|
||||
* 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.roomdetails.impl.invite
|
||||
|
||||
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.roomdetails.aMatrixRoom
|
||||
import io.element.android.features.roomdetails.impl.members.RoomMemberListDataSource
|
||||
import io.element.android.features.roomdetails.impl.members.aRoomMember
|
||||
import io.element.android.features.roomdetails.impl.members.aRoomMemberList
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
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.RoomMembershipState
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
||||
import io.element.android.libraries.usersearch.test.FakeUserRepository
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
internal class RoomInviteMembersPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - initial state has no results and no search`() = runTest {
|
||||
val presenter = RoomInviteMembersPresenter(
|
||||
userRepository = FakeUserRepository(),
|
||||
roomMemberListDataSource = createDataSource(FakeMatrixRoom()),
|
||||
coroutineDispatchers = testCoroutineDispatchers()
|
||||
)
|
||||
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
||||
assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.NotSearching::class.java)
|
||||
assertThat(initialState.isSearchActive).isFalse()
|
||||
assertThat(initialState.canInvite).isFalse()
|
||||
assertThat(initialState.searchQuery).isEmpty()
|
||||
|
||||
skipItems(1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - updates search active state`() = runTest {
|
||||
val presenter = RoomInviteMembersPresenter(
|
||||
userRepository = FakeUserRepository(),
|
||||
roomMemberListDataSource = createDataSource(FakeMatrixRoom()),
|
||||
coroutineDispatchers = testCoroutineDispatchers()
|
||||
)
|
||||
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
skipItems(1)
|
||||
|
||||
initialState.eventSink(RoomInviteMembersEvents.OnSearchActiveChanged(true))
|
||||
|
||||
val resultState = awaitItem()
|
||||
assertThat(resultState.isSearchActive).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - performs search and handles no results`() = runTest {
|
||||
val repository = FakeUserRepository()
|
||||
val presenter = RoomInviteMembersPresenter(
|
||||
userRepository = repository,
|
||||
roomMemberListDataSource = createDataSource(FakeMatrixRoom()),
|
||||
coroutineDispatchers = testCoroutineDispatchers()
|
||||
)
|
||||
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
skipItems(1)
|
||||
|
||||
initialState.eventSink(RoomInviteMembersEvents.UpdateSearchQuery("some query"))
|
||||
skipItems(1)
|
||||
|
||||
assertThat(repository.providedQuery).isEqualTo("some query")
|
||||
repository.emitResult(emptyList())
|
||||
skipItems(1)
|
||||
|
||||
val resultState = awaitItem()
|
||||
assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.NoResults::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - performs search and handles user results`() = runTest {
|
||||
val repository = FakeUserRepository()
|
||||
val presenter = RoomInviteMembersPresenter(
|
||||
userRepository = repository,
|
||||
roomMemberListDataSource = createDataSource(FakeMatrixRoom()),
|
||||
coroutineDispatchers = testCoroutineDispatchers()
|
||||
)
|
||||
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
skipItems(1)
|
||||
|
||||
initialState.eventSink(RoomInviteMembersEvents.UpdateSearchQuery("some query"))
|
||||
skipItems(1)
|
||||
|
||||
assertThat(repository.providedQuery).isEqualTo("some query")
|
||||
repository.emitResult(aMatrixUserList())
|
||||
skipItems(1)
|
||||
|
||||
val resultState = awaitItem()
|
||||
assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
|
||||
|
||||
val expectedUsers = aMatrixUserList()
|
||||
val users = resultState.searchResults.users()
|
||||
expectedUsers.forEachIndexed { index, matrixUser ->
|
||||
assertThat(users[index].matrixUser).isEqualTo(matrixUser)
|
||||
assertThat(users[index].isAlreadyInvited).isFalse()
|
||||
assertThat(users[index].isAlreadyJoined).isFalse()
|
||||
assertThat(users[index].isSelected).isFalse()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - performs search and handles membership state of existing users`() = runTest {
|
||||
val userList = aMatrixUserList()
|
||||
val joinedUser = userList[0]
|
||||
val invitedUser = userList[1]
|
||||
|
||||
val repository = FakeUserRepository()
|
||||
val presenter = RoomInviteMembersPresenter(
|
||||
userRepository = repository,
|
||||
roomMemberListDataSource = createDataSource(FakeMatrixRoom().apply {
|
||||
givenRoomMembersState(MatrixRoomMembersState.Ready(listOf(
|
||||
aRoomMember(userId = joinedUser.userId, membership = RoomMembershipState.JOIN),
|
||||
aRoomMember(userId = invitedUser.userId, membership = RoomMembershipState.INVITE),
|
||||
)))
|
||||
}),
|
||||
coroutineDispatchers = testCoroutineDispatchers()
|
||||
)
|
||||
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
skipItems(1)
|
||||
|
||||
initialState.eventSink(RoomInviteMembersEvents.UpdateSearchQuery("some query"))
|
||||
skipItems(1)
|
||||
|
||||
assertThat(repository.providedQuery).isEqualTo("some query")
|
||||
repository.emitResult(aMatrixUserList())
|
||||
skipItems(1)
|
||||
|
||||
val resultState = awaitItem()
|
||||
assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
|
||||
|
||||
val users = resultState.searchResults.users()
|
||||
|
||||
// The result that matches a user with JOINED membership is marked as such
|
||||
val userWhoShouldBeJoined = users.find { it.matrixUser == joinedUser }
|
||||
assertThat(userWhoShouldBeJoined).isNotNull()
|
||||
assertThat(userWhoShouldBeJoined?.isAlreadyJoined).isTrue()
|
||||
assertThat(userWhoShouldBeJoined?.isAlreadyInvited).isFalse()
|
||||
|
||||
// The result that matches a user with INVITED membership is marked as such
|
||||
val userWhoShouldBeInvited = users.find { it.matrixUser == invitedUser }
|
||||
assertThat(userWhoShouldBeInvited).isNotNull()
|
||||
assertThat(userWhoShouldBeInvited?.isAlreadyJoined).isFalse()
|
||||
assertThat(userWhoShouldBeInvited?.isAlreadyInvited).isTrue()
|
||||
|
||||
// All other users are neither joined nor invited
|
||||
val otherUsers = users.minus(userWhoShouldBeInvited!!).minus(userWhoShouldBeJoined!!)
|
||||
assertThat(otherUsers.none { it.isAlreadyInvited }).isTrue()
|
||||
assertThat(otherUsers.none { it.isAlreadyJoined }).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - toggle users updates selected user state`() = runTest {
|
||||
val repository = FakeUserRepository()
|
||||
val presenter = RoomInviteMembersPresenter(
|
||||
userRepository = repository,
|
||||
roomMemberListDataSource = createDataSource(),
|
||||
coroutineDispatchers = testCoroutineDispatchers()
|
||||
)
|
||||
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
skipItems(1)
|
||||
|
||||
// When we toggle a user not in the list, they are added
|
||||
initialState.eventSink(RoomInviteMembersEvents.ToggleUser(aMatrixUser()))
|
||||
assertThat(awaitItem().selectedUsers).containsExactly(aMatrixUser())
|
||||
|
||||
// Toggling a different user also adds them
|
||||
initialState.eventSink(RoomInviteMembersEvents.ToggleUser(aMatrixUser(id = A_USER_ID_2.value)))
|
||||
assertThat(awaitItem().selectedUsers).containsExactly(aMatrixUser(), aMatrixUser(id = A_USER_ID_2.value))
|
||||
|
||||
// Toggling the first user removes them
|
||||
initialState.eventSink(RoomInviteMembersEvents.ToggleUser(aMatrixUser()))
|
||||
assertThat(awaitItem().selectedUsers).containsExactly(aMatrixUser(id = A_USER_ID_2.value))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - selected users appear as such in search results`() = runTest {
|
||||
val repository = FakeUserRepository()
|
||||
val presenter = RoomInviteMembersPresenter(
|
||||
userRepository = repository,
|
||||
roomMemberListDataSource = createDataSource(FakeMatrixRoom()),
|
||||
coroutineDispatchers = testCoroutineDispatchers()
|
||||
)
|
||||
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
skipItems(1)
|
||||
|
||||
val selectedUser = aMatrixUser()
|
||||
|
||||
initialState.eventSink(RoomInviteMembersEvents.ToggleUser(selectedUser))
|
||||
|
||||
initialState.eventSink(RoomInviteMembersEvents.UpdateSearchQuery("some query"))
|
||||
skipItems(1)
|
||||
|
||||
assertThat(repository.providedQuery).isEqualTo("some query")
|
||||
repository.emitResult(aMatrixUserList() + selectedUser)
|
||||
skipItems(2)
|
||||
|
||||
val resultState = awaitItem()
|
||||
assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
|
||||
|
||||
val users = resultState.searchResults.users()
|
||||
|
||||
// The one user we have previously toggled is marked as selected
|
||||
val shouldBeSelectedUser = users.find { it.matrixUser == selectedUser }
|
||||
assertThat(shouldBeSelectedUser).isNotNull()
|
||||
assertThat(shouldBeSelectedUser?.isSelected).isTrue()
|
||||
|
||||
// And no others are
|
||||
val allOtherUsers = users.minus(shouldBeSelectedUser!!)
|
||||
assertThat(allOtherUsers.none { it.isSelected }).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun `present - toggling a user updates existing search results`() = runTest {
|
||||
val repository = FakeUserRepository()
|
||||
val presenter = RoomInviteMembersPresenter(
|
||||
userRepository = repository,
|
||||
roomMemberListDataSource = createDataSource(FakeMatrixRoom()),
|
||||
coroutineDispatchers = testCoroutineDispatchers()
|
||||
)
|
||||
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
skipItems(1)
|
||||
|
||||
val selectedUser = aMatrixUser()
|
||||
|
||||
// Given a query is made
|
||||
initialState.eventSink(RoomInviteMembersEvents.UpdateSearchQuery("some query"))
|
||||
skipItems(1)
|
||||
|
||||
assertThat(repository.providedQuery).isEqualTo("some query")
|
||||
repository.emitResult(aMatrixUserList() + selectedUser)
|
||||
skipItems(2)
|
||||
|
||||
// And then a user is toggled
|
||||
initialState.eventSink(RoomInviteMembersEvents.ToggleUser(selectedUser))
|
||||
skipItems(1)
|
||||
val resultState = awaitItem()
|
||||
|
||||
// The results are updated...
|
||||
assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
|
||||
val users = resultState.searchResults.users()
|
||||
|
||||
// The one user we have now toggled is marked as selected
|
||||
val shouldBeSelectedUser = users.find { it.matrixUser == selectedUser }
|
||||
assertThat(shouldBeSelectedUser).isNotNull()
|
||||
assertThat(shouldBeSelectedUser?.isSelected).isTrue()
|
||||
|
||||
// And no others are
|
||||
val allOtherUsers = users.minus(shouldBeSelectedUser!!)
|
||||
assertThat(allOtherUsers.none { it.isSelected }).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createDataSource(
|
||||
matrixRoom: MatrixRoom = aMatrixRoom().apply {
|
||||
givenRoomMembersState(MatrixRoomMembersState.Ready(aRoomMemberList()))
|
||||
},
|
||||
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers()
|
||||
) = RoomMemberListDataSource(matrixRoom, coroutineDispatchers)
|
||||
|
||||
private fun SearchBarResultState<ImmutableList<InvitableUser>>.users() =
|
||||
(this as? SearchBarResultState.Results<ImmutableList<InvitableUser>>)?.results.orEmpty()
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
@@ -113,6 +114,54 @@ class RoomMemberListPresenterTests {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - asynchronously sets canInvite when user has correct power level`() = runTest {
|
||||
val presenter = createPresenter(
|
||||
matrixRoom = FakeMatrixRoom().apply {
|
||||
givenCanInviteResult(Result.success(true))
|
||||
}
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val loadedState = awaitItem()
|
||||
Truth.assertThat(loadedState.canInvite).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - asynchronously sets canInvite when user does not have correct power level`() = runTest {
|
||||
val presenter = createPresenter(
|
||||
matrixRoom = FakeMatrixRoom().apply {
|
||||
givenCanInviteResult(Result.success(false))
|
||||
}
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val loadedState = awaitItem()
|
||||
Truth.assertThat(loadedState.canInvite).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - asynchronously sets canInvite when power level check fails`() = runTest {
|
||||
val presenter = createPresenter(
|
||||
matrixRoom = FakeMatrixRoom().apply {
|
||||
givenCanInviteResult(Result.failure(Throwable("Eek")))
|
||||
}
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val loadedState = awaitItem()
|
||||
Truth.assertThat(loadedState.canInvite).isFalse()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalCoroutinesApi
|
||||
@@ -125,6 +174,7 @@ private fun createDataSource(
|
||||
|
||||
@ExperimentalCoroutinesApi
|
||||
private fun createPresenter(
|
||||
matrixRoom: MatrixRoom = FakeMatrixRoom(),
|
||||
roomMemberListDataSource: RoomMemberListDataSource = createDataSource(),
|
||||
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers()
|
||||
) = RoomMemberListPresenter(roomMemberListDataSource, coroutineDispatchers)
|
||||
) = RoomMemberListPresenter(matrixRoom, roomMemberListDataSource, coroutineDispatchers)
|
||||
|
||||
@@ -57,6 +57,7 @@ fun <T> SearchBar(
|
||||
placeHolderTitle: String,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
showBackButton: Boolean = true,
|
||||
resultState: SearchBarResultState<T> = SearchBarResultState.NotSearching(),
|
||||
shape: Shape = SearchBarDefaults.inputFieldShape,
|
||||
tonalElevation: Dp = SearchBarDefaults.Elevation,
|
||||
@@ -87,7 +88,7 @@ fun <T> SearchBar(
|
||||
modifier = Modifier.alpha(0.4f), // FIXME align on Design system theme (removing alpha should be fine)
|
||||
)
|
||||
},
|
||||
leadingIcon = if (active) {
|
||||
leadingIcon = if (showBackButton && active) {
|
||||
{ BackButton(onClick = { onActiveChange(false) }) }
|
||||
} else {
|
||||
null
|
||||
@@ -179,6 +180,16 @@ internal fun SearchBarPreviewActiveWithQuery() = ElementThemedPreview {
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(group = PreviewGroup.Search)
|
||||
@Composable
|
||||
internal fun SearchBarPreviewActiveWithQueryNoBackButton() = ElementThemedPreview {
|
||||
ContentToPreview(
|
||||
query = "search term",
|
||||
active = true,
|
||||
showBackButton = false,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(group = PreviewGroup.Search)
|
||||
@Composable
|
||||
internal fun SearchBarPreviewActiveWithNoResults() = ElementThemedPreview {
|
||||
@@ -212,6 +223,7 @@ internal fun SearchBarPreviewActiveWithContent() = ElementThemedPreview {
|
||||
private fun ContentToPreview(
|
||||
query: String = "",
|
||||
active: Boolean = false,
|
||||
showBackButton: Boolean = true,
|
||||
resultState: SearchBarResultState<String> = SearchBarResultState.NotSearching(),
|
||||
contentPrefix: @Composable ColumnScope.() -> Unit = {},
|
||||
contentSuffix: @Composable ColumnScope.() -> Unit = {},
|
||||
@@ -221,6 +233,7 @@ private fun ContentToPreview(
|
||||
query = query,
|
||||
active = active,
|
||||
resultState = resultState,
|
||||
showBackButton = showBackButton,
|
||||
onQueryChange = {},
|
||||
onActiveChange = {},
|
||||
placeHolderTitle = "Search for things",
|
||||
|
||||
@@ -85,4 +85,8 @@ interface MatrixRoom : Closeable {
|
||||
suspend fun acceptInvitation(): Result<Unit>
|
||||
|
||||
suspend fun rejectInvitation(): Result<Unit>
|
||||
|
||||
suspend fun inviteUserById(id: UserId): Result<Unit>
|
||||
|
||||
suspend fun canInvite(): Result<Boolean>
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.matrix.rustcomponents.sdk.Room
|
||||
import org.matrix.rustcomponents.sdk.RoomMember
|
||||
import org.matrix.rustcomponents.sdk.SlidingSyncRoom
|
||||
import org.matrix.rustcomponents.sdk.UpdateSummary
|
||||
import org.matrix.rustcomponents.sdk.genTransactionId
|
||||
@@ -209,6 +210,18 @@ class RustMatrixRoom(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun inviteUserById(id: UserId): Result<Unit> = withContext(coroutineDispatchers.io) {
|
||||
runCatching {
|
||||
innerRoom.inviteUserById(id.value)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun canInvite(): Result<Boolean> = withContext(coroutineDispatchers.io) {
|
||||
runCatching {
|
||||
innerRoom.member(sessionId.value).use(RoomMember::canInvite)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo): Result<Unit> {
|
||||
return runCatching {
|
||||
innerRoom.sendImage(file.path, thumbnailFile.path, imageInfo.map())
|
||||
|
||||
@@ -152,6 +152,7 @@ class RustMatrixTimeline(
|
||||
RequiredState(key = "m.room.canonical_alias", value = ""),
|
||||
RequiredState(key = "m.room.topic", value = ""),
|
||||
RequiredState(key = "m.room.join_rules", value = ""),
|
||||
RequiredState(key = "m.room.power_levels", value = ""),
|
||||
),
|
||||
timelineLimit = null
|
||||
)
|
||||
|
||||
@@ -59,6 +59,8 @@ class FakeMatrixRoom(
|
||||
private var updateMembersResult: Result<Unit> = Result.success(Unit)
|
||||
private var acceptInviteResult = Result.success(Unit)
|
||||
private var rejectInviteResult = Result.success(Unit)
|
||||
private var inviteUserResult = Result.success(Unit)
|
||||
private var canInviteResult = Result.success(true)
|
||||
private var sendMediaResult = Result.success(Unit)
|
||||
var sendMediaCount = 0
|
||||
private set
|
||||
@@ -69,6 +71,9 @@ class FakeMatrixRoom(
|
||||
var isInviteRejected: Boolean = false
|
||||
private set
|
||||
|
||||
var invitedUserId: UserId? = null
|
||||
private set
|
||||
|
||||
private var leaveRoomError: Throwable? = null
|
||||
|
||||
override val membersStateFlow: MutableStateFlow<MatrixRoomMembersState> = MutableStateFlow(MatrixRoomMembersState.Unknown)
|
||||
@@ -136,6 +141,15 @@ class FakeMatrixRoom(
|
||||
return rejectInviteResult
|
||||
}
|
||||
|
||||
override suspend fun inviteUserById(id: UserId): Result<Unit> {
|
||||
invitedUserId = id
|
||||
return inviteUserResult
|
||||
}
|
||||
|
||||
override suspend fun canInvite(): Result<Boolean> {
|
||||
return canInviteResult
|
||||
}
|
||||
|
||||
override suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo): Result<Unit> = sendMediaResult.also { sendMediaCount++ }
|
||||
|
||||
override suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo): Result<Unit> = sendMediaResult.also { sendMediaCount++ }
|
||||
@@ -174,6 +188,14 @@ class FakeMatrixRoom(
|
||||
rejectInviteResult = result
|
||||
}
|
||||
|
||||
fun givenInviteUserResult(result: Result<Unit>) {
|
||||
inviteUserResult = result
|
||||
}
|
||||
|
||||
fun givenCanInviteResult(result: Result<Boolean>) {
|
||||
canInviteResult = result
|
||||
}
|
||||
|
||||
fun givenIgnoreResult(result: Result<Unit>) {
|
||||
ignoreResult = result
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
<string name="common_file">"File"</string>
|
||||
<string name="common_gif">"GIF"</string>
|
||||
<string name="common_image">"Image"</string>
|
||||
<string name="common_leaving_room">"Leaving room"</string>
|
||||
<string name="common_link_copied_to_clipboard">"Link copied to clipboard"</string>
|
||||
<string name="common_loading">"Loading…"</string>
|
||||
<string name="common_message">"Message"</string>
|
||||
@@ -98,6 +99,8 @@
|
||||
<string name="common_suggestions">"Suggestions"</string>
|
||||
<string name="common_topic">"Topic"</string>
|
||||
<string name="common_unable_to_decrypt">"Unable to decrypt"</string>
|
||||
<string name="common_unable_to_invite_message">"We were unable to successfully send invites to one or more users."</string>
|
||||
<string name="common_unable_to_invite_title">"Unable to send invite(s)"</string>
|
||||
<string name="common_unsupported_event">"Unsupported event"</string>
|
||||
<string name="common_username">"Username"</string>
|
||||
<string name="common_verification_cancelled">"Verification cancelled"</string>
|
||||
@@ -162,4 +165,4 @@
|
||||
<string name="screen_analytics_settings_read_terms">"You can read all our terms %1$s."</string>
|
||||
<string name="screen_analytics_settings_read_terms_content_link">"here"</string>
|
||||
<string name="screen_report_content_block_user">"Block user"</string>
|
||||
</resources>
|
||||
</resources>
|
||||
@@ -95,6 +95,7 @@ fun DependencyHandlerScope.allLibrariesImpl() {
|
||||
|
||||
fun DependencyHandlerScope.allServicesImpl() {
|
||||
implementation(project(":services:analytics:noop"))
|
||||
implementation(project(":services:apperror:impl"))
|
||||
implementation(project(":services:appnavstate:impl"))
|
||||
implementation(project(":services:toolbox:impl"))
|
||||
}
|
||||
|
||||
27
services/apperror/api/build.gradle.kts
Normal file
27
services/apperror/api/build.gradle.kts
Normal file
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* 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-library")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.services.apperror.api"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.coroutines.core)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* 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.services.apperror.api
|
||||
|
||||
sealed interface AppErrorState {
|
||||
|
||||
object NoError : AppErrorState
|
||||
|
||||
data class Error(
|
||||
val title: String,
|
||||
val body: String,
|
||||
val dismiss: () -> Unit,
|
||||
) : AppErrorState
|
||||
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* 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.services.apperror.api
|
||||
|
||||
fun aAppErrorState() = AppErrorState.Error(
|
||||
title = "An error occurred",
|
||||
body = "Something went wrong, and the details of that would go here.",
|
||||
dismiss = {},
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* 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.services.apperror.api
|
||||
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
interface AppErrorStateService {
|
||||
|
||||
val appErrorStateFlow: StateFlow<AppErrorState>
|
||||
|
||||
fun showError(title: String, body: String)
|
||||
|
||||
}
|
||||
51
services/apperror/impl/build.gradle.kts
Normal file
51
services/apperror/impl/build.gradle.kts
Normal file
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* 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.ksp)
|
||||
alias(libs.plugins.anvil)
|
||||
}
|
||||
|
||||
anvil {
|
||||
generateDaggerFactories.set(true)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.services.apperror.impl"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
anvil(projects.anvilcodegen)
|
||||
implementation(libs.dagger)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.di)
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
implementation(projects.anvilannotations)
|
||||
|
||||
implementation(libs.coroutines.core)
|
||||
implementation(libs.androidx.corektx)
|
||||
|
||||
api(projects.services.apperror.api)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.coroutines.test)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(libs.test.truth)
|
||||
|
||||
ksp(libs.showkase.processor)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* 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.services.apperror.impl
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
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.services.apperror.api.AppErrorState
|
||||
import io.element.android.services.apperror.api.aAppErrorState
|
||||
|
||||
@Composable
|
||||
fun AppErrorView(
|
||||
state: AppErrorState,
|
||||
) {
|
||||
if (state is AppErrorState.Error) {
|
||||
AppErrorViewContent(
|
||||
title = state.title,
|
||||
body = state.body,
|
||||
onDismiss = state.dismiss,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppErrorViewContent(
|
||||
title: String,
|
||||
body: String,
|
||||
onDismiss: () -> Unit = { },
|
||||
) {
|
||||
ErrorDialog(
|
||||
title = title,
|
||||
content = body,
|
||||
onDismiss = onDismiss,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun AppErrorViewLightPreview() = ElementPreviewLight { ContentToPreview() }
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun AppErrorViewDarkPreview() = ElementPreviewDark { ContentToPreview() }
|
||||
|
||||
@Composable
|
||||
private fun ContentToPreview() {
|
||||
AppErrorView(
|
||||
state = aAppErrorState()
|
||||
)
|
||||
}
|
||||
@@ -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.services.apperror.impl
|
||||
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.di.SingleIn
|
||||
import io.element.android.services.apperror.api.AppErrorState
|
||||
import io.element.android.services.apperror.api.AppErrorStateService
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import javax.inject.Inject
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
@SingleIn(AppScope::class)
|
||||
class DefaultAppErrorStateService @Inject constructor() : AppErrorStateService {
|
||||
|
||||
private val currentAppErrorState = MutableStateFlow<AppErrorState>(AppErrorState.NoError)
|
||||
override val appErrorStateFlow: StateFlow<AppErrorState> = currentAppErrorState
|
||||
|
||||
override fun showError(title: String, body: String) {
|
||||
currentAppErrorState.value = AppErrorState.Error(
|
||||
title = title,
|
||||
body = body,
|
||||
dismiss = {
|
||||
currentAppErrorState.value = AppErrorState.NoError
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* 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.services.apperror.impl
|
||||
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.services.apperror.api.AppErrorState
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
internal class DefaultAppErrorStateServiceTest {
|
||||
|
||||
@Test
|
||||
fun `initial value is no error`() = runTest {
|
||||
val service = DefaultAppErrorStateService()
|
||||
|
||||
service.appErrorStateFlow.test {
|
||||
val state = awaitItem()
|
||||
assertThat(state).isInstanceOf(AppErrorState.NoError::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `showError - emits value`() = runTest {
|
||||
val service = DefaultAppErrorStateService()
|
||||
|
||||
service.appErrorStateFlow.test {
|
||||
skipItems(1)
|
||||
|
||||
service.showError("Title", "Body")
|
||||
val state = awaitItem()
|
||||
assertThat(state).isInstanceOf(AppErrorState.Error::class.java)
|
||||
|
||||
val errorState = state as AppErrorState.Error
|
||||
assertThat(errorState.title).isEqualTo("Title")
|
||||
assertThat(errorState.body).isEqualTo("Body")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dismiss - clears value`() = runTest {
|
||||
val service = DefaultAppErrorStateService()
|
||||
|
||||
service.appErrorStateFlow.test {
|
||||
skipItems(1)
|
||||
|
||||
service.showError("Title", "Body")
|
||||
val state = awaitItem()
|
||||
assertThat(state).isInstanceOf(AppErrorState.Error::class.java)
|
||||
|
||||
val errorState = state as AppErrorState.Error
|
||||
errorState.dismiss()
|
||||
|
||||
assertThat(awaitItem()).isInstanceOf(AppErrorState.NoError::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user