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:
Chris Smith
2023-05-23 10:23:24 +01:00
committed by GitHub
parent 882a155f07
commit 463b9e0642
85 changed files with 1668 additions and 69 deletions

View File

@@ -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)

View File

@@ -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,
)
}
}

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)
}
}

View File

@@ -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
View File

@@ -0,0 +1 @@
[Create and join rooms] Add ability to invite users to existing rooms

View File

@@ -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,
)
}
}

View File

@@ -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(

View File

@@ -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 = {

View File

@@ -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)

View File

@@ -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)))
}

View File

@@ -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,
)
}
}

View File

@@ -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) {

View File

@@ -32,6 +32,7 @@ data class RoomDetailsState(
val error: RoomDetailsError?,
val roomType: RoomDetailsType,
val roomMemberDetailsState: RoomMemberDetailsState?,
val canInvite: Boolean,
val eventSink: (RoomDetailsEvent) -> Unit
)

View File

@@ -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 = {}

View File

@@ -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 = {},
)
}

View File

@@ -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
}

View File

@@ -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()
}
}
)
}
}

View File

@@ -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)
}
}

View File

@@ -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,
)

View File

@@ -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),
)
)
),
)
}

View File

@@ -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)
}

View File

@@ -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,
)
}
}

View File

@@ -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
}
}

View File

@@ -26,6 +26,7 @@ data class RoomMemberListState(
val searchQuery: String,
val searchResults: SearchBarResultState<RoomMembers>,
val isSearchActive: Boolean,
val canInvite: Boolean,
val eventSink: (RoomMemberListEvents) -> Unit,
)

View File

@@ -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 = {}
)

View File

@@ -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 = {},
)
}

View File

@@ -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(

View File

@@ -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()
}

View File

@@ -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)

View File

@@ -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",

View File

@@ -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>
}

View File

@@ -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())

View File

@@ -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
)

View File

@@ -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
}

View File

@@ -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>

View File

@@ -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"))
}

View 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)
}

View File

@@ -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
}

View File

@@ -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 = {},
)

View File

@@ -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)
}

View 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)
}

View File

@@ -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()
)
}

View File

@@ -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
},
)
}
}

View File

@@ -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)
}
}
}