Merge branch 'develop' into feature/fga/image_loading

This commit is contained in:
ganfra 2023-05-22 20:59:37 +02:00
commit 63513ae2da
212 changed files with 1616 additions and 916 deletions

View file

@ -1,40 +0,0 @@
/*
* 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.createroom.impl
import io.element.android.features.userlist.api.UserListDataSource
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
import javax.inject.Inject
class AllMatrixUsersDataSource @Inject constructor(
private val client: MatrixClient
) : UserListDataSource {
override suspend fun search(query: String): List<MatrixUser> {
val res = client.searchUsers(query, MAX_SEARCH_RESULTS)
return res.getOrNull()?.results.orEmpty()
}
override suspend fun getProfile(userId: UserId): MatrixUser? {
return client.getProfile(userId).getOrNull()
}
companion object {
private const val MAX_SEARCH_RESULTS = 5L
}
}

View file

@ -16,6 +16,7 @@
package io.element.android.features.createroom.impl
import android.net.Uri
import io.element.android.features.createroom.impl.configureroom.RoomPrivacy
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList
@ -24,7 +25,7 @@ import kotlinx.collections.immutable.persistentListOf
data class CreateRoomConfig(
val roomName: String? = null,
val topic: String? = null,
val avatarUrl: String? = null,
val avatarUri: Uri? = null,
val invites: ImmutableList<MatrixUser> = persistentListOf(),
val privacy: RoomPrivacy? = null,
val privacy: RoomPrivacy = RoomPrivacy.Private,
)

View file

@ -16,14 +16,16 @@
package io.element.android.features.createroom.impl
import android.net.Uri
import io.element.android.features.createroom.impl.configureroom.RoomPrivacy
import io.element.android.features.createroom.impl.di.CreateRoomScope
import io.element.android.features.userlist.api.UserListDataStore
import io.element.android.features.createroom.impl.userlist.UserListDataStore
import io.element.android.libraries.di.SingleIn
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import java.io.File
import javax.inject.Inject
@SingleIn(CreateRoomScope::class)
@ -32,6 +34,11 @@ class CreateRoomDataStore @Inject constructor(
) {
private val createRoomConfigFlow: MutableStateFlow<CreateRoomConfig> = MutableStateFlow(CreateRoomConfig())
private var cachedAvatarUri: Uri? = null
set(value) {
field?.path?.let { File(it) }?.delete()
field = value
}
fun getCreateRoomConfig(): Flow<CreateRoomConfig> = combine(
selectedUserListDataStore.selectedUsers(),
@ -48,11 +55,16 @@ class CreateRoomDataStore @Inject constructor(
createRoomConfigFlow.tryEmit(createRoomConfigFlow.value.copy(topic = topic?.takeIf { it.isNotEmpty() }))
}
fun setAvatarUrl(avatarUrl: String?) {
createRoomConfigFlow.tryEmit(createRoomConfigFlow.value.copy(avatarUrl = avatarUrl))
fun setAvatarUri(uri: Uri?, cached: Boolean = false) {
cachedAvatarUri = uri.takeIf { cached }
createRoomConfigFlow.tryEmit(createRoomConfigFlow.value.copy(avatarUri = uri))
}
fun setPrivacy(privacy: RoomPrivacy?) {
fun setPrivacy(privacy: RoomPrivacy) {
createRoomConfigFlow.tryEmit(createRoomConfigFlow.value.copy(privacy = privacy))
}
fun clearCachedData() {
cachedAvatarUri = null
}
}

View file

@ -18,18 +18,17 @@ package io.element.android.features.createroom.impl.addpeople
import androidx.compose.runtime.Composable
import io.element.android.features.createroom.impl.CreateRoomDataStore
import io.element.android.features.userlist.api.SelectionMode
import io.element.android.features.userlist.api.UserListDataSource
import io.element.android.features.userlist.api.UserListPresenter
import io.element.android.features.userlist.api.UserListPresenterArgs
import io.element.android.features.userlist.api.UserListState
import io.element.android.features.createroom.impl.userlist.SelectionMode
import io.element.android.features.createroom.impl.userlist.UserListPresenter
import io.element.android.features.createroom.impl.userlist.UserListPresenterArgs
import io.element.android.features.createroom.impl.userlist.UserListState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.usersearch.api.UserRepository
import javax.inject.Inject
import javax.inject.Named
class AddPeoplePresenter @Inject constructor(
private val userListPresenterFactory: UserListPresenter.Factory,
@Named("AllUsers") private val userListDataSource: UserListDataSource,
private val userRepository: UserRepository,
private val dataStore: CreateRoomDataStore,
) : Presenter<UserListState> {
@ -37,10 +36,8 @@ class AddPeoplePresenter @Inject constructor(
userListPresenterFactory.create(
UserListPresenterArgs(
selectionMode = SelectionMode.Multiple,
minimumSearchLength = 3,
searchDebouncePeriodMillis = UserListPresenterArgs.DEFAULT_DEBOUNCE
),
userListDataSource,
userRepository,
dataStore.selectedUserListDataStore,
)
}

View file

@ -17,11 +17,11 @@
package io.element.android.features.createroom.impl.addpeople
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.userlist.api.SelectionMode
import io.element.android.features.userlist.api.UserListState
import io.element.android.features.userlist.api.UserSearchResultState
import io.element.android.features.userlist.api.aListOfSelectedUsers
import io.element.android.features.userlist.api.aUserListState
import io.element.android.features.createroom.impl.userlist.SelectionMode
import io.element.android.features.createroom.impl.userlist.UserListState
import io.element.android.features.createroom.impl.userlist.aListOfSelectedUsers
import io.element.android.features.createroom.impl.userlist.aUserListState
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import kotlinx.collections.immutable.toImmutableList
@ -30,13 +30,13 @@ open class AddPeopleUserListStateProvider : PreviewParameterProvider<UserListSta
get() = sequenceOf(
aUserListState(),
aUserListState().copy(
searchResults = UserSearchResultState.Results(aMatrixUserList().toImmutableList()),
searchResults = SearchBarResultState.Results(aMatrixUserList().toImmutableList()),
selectedUsers = aListOfSelectedUsers(),
isSearchActive = false,
selectionMode = SelectionMode.Multiple,
),
aUserListState().copy(
searchResults = UserSearchResultState.Results(aMatrixUserList().toImmutableList()),
searchResults = SearchBarResultState.Results(aMatrixUserList().toImmutableList()),
selectedUsers = aListOfSelectedUsers(),
isSearchActive = true,
selectionMode = SelectionMode.Multiple,

View file

@ -17,6 +17,8 @@
package io.element.android.features.createroom.impl.addpeople
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@ -30,8 +32,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
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.userlist.api.UserListState
import io.element.android.features.userlist.api.components.UserListView
import io.element.android.features.createroom.impl.components.UserListView
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
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
@ -41,7 +43,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun AddPeopleView(
state: UserListState,
@ -50,6 +52,7 @@ fun AddPeopleView(
onNextPressed: () -> Unit = {},
) {
Scaffold(
modifier = modifier,
topBar = {
if (!state.isSearchActive) {
AddPeopleViewTopBar(
@ -61,12 +64,14 @@ fun AddPeopleView(
}
) { padding ->
Column(
modifier = modifier
modifier = Modifier
.fillMaxSize()
.padding(padding),
.padding(padding)
.consumeWindowInsets(padding),
) {
UserListView(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth(),
state = state,
)
}

View file

@ -54,6 +54,7 @@ fun LabelledTextField(
value = value,
placeholder = { Text(placeholder) },
onValueChange = onValueChange,
singleLine = maxLines == 1,
maxLines = maxLines,
)
}

View file

@ -0,0 +1,61 @@
/*
* 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.createroom.impl.components
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.CheckableMatrixUserRow
import io.element.android.libraries.matrix.ui.components.aMatrixUser
@Composable
fun SearchMultipleUsersResultItem(
matrixUser: MatrixUser,
isUserSelected: Boolean,
modifier: Modifier = Modifier,
onCheckedChange: (Boolean) -> Unit = {},
) {
CheckableMatrixUserRow(
checked = isUserSelected,
modifier = modifier,
matrixUser = matrixUser,
avatarSize = AvatarSize.Custom(36.dp),
onCheckedChange = onCheckedChange,
)
}
@Preview
@Composable
internal fun SearchMultipleUsersResultItemLightPreview() = ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
internal fun SearchMultipleUsersResultItemDarkPreview() = ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
Column {
SearchMultipleUsersResultItem(matrixUser = aMatrixUser(), isUserSelected = true)
SearchMultipleUsersResultItem(matrixUser = aMatrixUser(), isUserSelected = false)
}
}

View file

@ -0,0 +1,55 @@
/*
* 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.createroom.impl.components
import androidx.compose.foundation.clickable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
import io.element.android.libraries.matrix.ui.components.aMatrixUser
@Composable
fun SearchSingleUserResultItem(
matrixUser: MatrixUser,
modifier: Modifier = Modifier,
onClick: () -> Unit = {},
) {
MatrixUserRow(
modifier = modifier.clickable(onClick = onClick),
matrixUser = matrixUser,
avatarSize = AvatarSize.Custom(36.dp),
)
}
@Preview
@Composable
internal fun SearchSingleUserResultItemLightPreview() = ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
internal fun SearchSingleUserResultItemDarkPreview() = ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
SearchSingleUserResultItem(matrixUser = aMatrixUser())
}

View file

@ -0,0 +1,95 @@
/*
* 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.createroom.impl.components
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.theme.components.SearchBar
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.SelectedUsersList
import io.element.android.libraries.ui.strings.R
import kotlinx.collections.immutable.ImmutableList
@Composable
fun SearchUserBar(
query: String,
state: SearchBarResultState<ImmutableList<MatrixUser>>,
selectedUsers: ImmutableList<MatrixUser>,
active: Boolean,
isMultiSelectionEnabled: Boolean,
modifier: Modifier = Modifier,
placeHolderTitle: String = stringResource(R.string.common_search_for_someone),
onActiveChanged: (Boolean) -> Unit = {},
onTextChanged: (String) -> Unit = {},
onUserSelected: (MatrixUser) -> Unit = {},
onUserDeselected: (MatrixUser) -> Unit = {},
) {
SearchBar(
query = query,
onQueryChange = onTextChanged,
active = active,
onActiveChange = onActiveChanged,
modifier = modifier,
placeHolderTitle = placeHolderTitle,
contentPrefix = {
if (isMultiSelectionEnabled && active && selectedUsers.isNotEmpty()) {
SelectedUsersList(
contentPadding = PaddingValues(16.dp),
selectedUsers = selectedUsers,
autoScroll = true,
onUserRemoved = onUserDeselected,
)
}
},
resultState = state,
resultHandler = { users ->
LazyColumn {
if (isMultiSelectionEnabled) {
items(users) { matrixUser ->
SearchMultipleUsersResultItem(
modifier = Modifier.fillMaxWidth(),
matrixUser = matrixUser,
isUserSelected = selectedUsers.find { it.userId == matrixUser.userId } != null,
onCheckedChange = { checked ->
if (checked) {
onUserSelected(matrixUser)
} else {
onUserDeselected(matrixUser)
}
}
)
}
} else {
items(users) { matrixUser ->
SearchSingleUserResultItem(
modifier = Modifier.fillMaxWidth(),
matrixUser = matrixUser,
onClick = { onUserSelected(matrixUser) }
)
}
}
}
},
)
}

View file

@ -0,0 +1,91 @@
/*
* 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.createroom.impl.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.createroom.impl.userlist.UserListEvents
import io.element.android.features.createroom.impl.userlist.UserListState
import io.element.android.features.createroom.impl.userlist.UserListStateProvider
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.SelectedUsersList
@Composable
fun UserListView(
state: UserListState,
modifier: Modifier = Modifier,
onUserSelected: (MatrixUser) -> Unit = {},
onUserDeselected: (MatrixUser) -> Unit = {},
) {
Column(
modifier = modifier,
) {
SearchUserBar(
modifier = Modifier.fillMaxWidth(),
query = state.searchQuery,
state = state.searchResults,
selectedUsers = state.selectedUsers,
active = state.isSearchActive,
isMultiSelectionEnabled = state.isMultiSelectionEnabled,
onActiveChanged = { state.eventSink(UserListEvents.OnSearchActiveChanged(it)) },
onTextChanged = { state.eventSink(UserListEvents.UpdateSearchQuery(it)) },
onUserSelected = {
state.eventSink(UserListEvents.AddToSelection(it))
onUserSelected(it)
},
onUserDeselected = {
state.eventSink(UserListEvents.RemoveFromSelection(it))
onUserDeselected(it)
},
)
if (state.isMultiSelectionEnabled && !state.isSearchActive && state.selectedUsers.isNotEmpty()) {
SelectedUsersList(
contentPadding = PaddingValues(16.dp),
selectedUsers = state.selectedUsers,
autoScroll = true,
onUserRemoved = {
state.eventSink(UserListEvents.RemoveFromSelection(it))
onUserDeselected(it)
},
)
}
}
}
@Preview
@Composable
internal fun UserListViewLightPreview(@PreviewParameter(UserListStateProvider::class) state: UserListState) =
ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
internal fun UserListViewDarkPreview(@PreviewParameter(UserListStateProvider::class) state: UserListState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable
private fun ContentToPreview(state: UserListState) {
UserListView(state = state)
}

View file

@ -16,16 +16,16 @@
package io.element.android.features.createroom.impl.configureroom
import android.net.Uri
import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.features.createroom.impl.configureroom.avatar.AvatarAction
import io.element.android.libraries.matrix.api.user.MatrixUser
sealed interface ConfigureRoomEvents {
data class RoomNameChanged(val name: String) : ConfigureRoomEvents
data class TopicChanged(val topic: String) : ConfigureRoomEvents
data class AvatarUriChanged(val uri: Uri?) : ConfigureRoomEvents
data class RoomPrivacyChanged(val privacy: RoomPrivacy?) : ConfigureRoomEvents
data class RoomPrivacyChanged(val privacy: RoomPrivacy) : ConfigureRoomEvents
data class RemoveFromSelection(val matrixUser: MatrixUser) : ConfigureRoomEvents
data class CreateRoom(val config: CreateRoomConfig) : ConfigureRoomEvents
data class HandleAvatarAction(val action: AvatarAction) : ConfigureRoomEvents
object CancelCreateRoom : ConfigureRoomEvents
}

View file

@ -16,6 +16,7 @@
package io.element.android.features.createroom.impl.configureroom
import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
@ -26,14 +27,19 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.features.createroom.impl.CreateRoomDataStore
import io.element.android.features.createroom.impl.configureroom.avatar.AvatarAction
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.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
import io.element.android.libraries.matrix.api.createroom.RoomPreset
import io.element.android.libraries.matrix.api.createroom.RoomVisibility
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -41,14 +47,28 @@ import javax.inject.Inject
class ConfigureRoomPresenter @Inject constructor(
private val dataStore: CreateRoomDataStore,
private val matrixClient: MatrixClient,
private val mediaPickerProvider: PickerProvider,
private val mediaPreProcessor: MediaPreProcessor,
) : Presenter<ConfigureRoomState> {
@Composable
override fun present(): ConfigureRoomState {
val createRoomConfig = dataStore.getCreateRoomConfig().collectAsState(CreateRoomConfig())
val isCreateButtonEnabled by remember(createRoomConfig.value.roomName, createRoomConfig.value.privacy) {
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(
onResult = { uri -> if (uri != null) dataStore.setAvatarUri(uri = uri, cached = true) },
)
val galleryImagePicker = mediaPickerProvider.registerGalleryImagePicker(
onResult = { uri -> if (uri != null) dataStore.setAvatarUri(uri = uri) }
)
val avatarActions by remember(createRoomConfig.value.avatarUri) {
derivedStateOf {
createRoomConfig.value.roomName.isNullOrEmpty().not() && createRoomConfig.value.privacy != null
listOfNotNull(
AvatarAction.TakePhoto,
AvatarAction.ChoosePhoto,
AvatarAction.Remove.takeIf { createRoomConfig.value.avatarUri != null },
).toImmutableList()
}
}
@ -62,26 +82,37 @@ class ConfigureRoomPresenter @Inject constructor(
fun handleEvents(event: ConfigureRoomEvents) {
when (event) {
is ConfigureRoomEvents.AvatarUriChanged -> dataStore.setAvatarUrl(event.uri?.toString())
is ConfigureRoomEvents.RoomNameChanged -> dataStore.setRoomName(event.name)
is ConfigureRoomEvents.TopicChanged -> dataStore.setTopic(event.topic)
is ConfigureRoomEvents.RoomPrivacyChanged -> dataStore.setPrivacy(event.privacy)
is ConfigureRoomEvents.RemoveFromSelection -> dataStore.selectedUserListDataStore.removeUserFromSelection(event.matrixUser)
is ConfigureRoomEvents.CreateRoom -> createRoom(event.config)
is ConfigureRoomEvents.HandleAvatarAction -> {
when (event.action) {
AvatarAction.ChoosePhoto -> galleryImagePicker.launch()
AvatarAction.TakePhoto -> cameraPhotoPicker.launch()
AvatarAction.Remove -> dataStore.setAvatarUri(uri = null)
}
}
ConfigureRoomEvents.CancelCreateRoom -> createRoomAction.value = Async.Uninitialized
}
}
return ConfigureRoomState(
config = createRoomConfig.value,
isCreateButtonEnabled = isCreateButtonEnabled,
avatarActions = avatarActions,
createRoomAction = createRoomAction.value,
eventSink = ::handleEvents,
)
}
private fun CoroutineScope.createRoom(config: CreateRoomConfig, createRoomAction: MutableState<Async<RoomId>>) = launch {
private fun CoroutineScope.createRoom(
config: CreateRoomConfig,
createRoomAction: MutableState<Async<RoomId>>
) = launch {
suspend {
val avatarUrl = config.avatarUri?.let { uploadAvatar(it) }
val params = CreateRoomParameters(
name = config.roomName,
topic = config.topic,
@ -90,9 +121,16 @@ class ConfigureRoomPresenter @Inject constructor(
visibility = if (config.privacy == RoomPrivacy.Public) RoomVisibility.PUBLIC else RoomVisibility.PRIVATE,
preset = if (config.privacy == RoomPrivacy.Public) RoomPreset.PUBLIC_CHAT else RoomPreset.PRIVATE_CHAT,
invite = config.invites.map { it.userId },
avatar = config.avatarUrl,
avatar = avatarUrl,
)
matrixClient.createRoom(params).getOrThrow()
.also { dataStore.clearCachedData() }
}.execute(createRoomAction)
}
private suspend fun uploadAvatar(avatarUri: Uri): String {
val preprocessed = mediaPreProcessor.process(avatarUri, MimeTypes.Jpeg, compressIfPossible = false).getOrThrow()
val byteArray = preprocessed.file.readBytes()
return matrixClient.uploadMedia(MimeTypes.Jpeg, byteArray).getOrThrow()
}
}

View file

@ -17,12 +17,16 @@
package io.element.android.features.createroom.impl.configureroom
import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.features.createroom.impl.configureroom.avatar.AvatarAction
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.collections.immutable.ImmutableList
data class ConfigureRoomState(
val config: CreateRoomConfig,
val isCreateButtonEnabled: Boolean,
val avatarActions: ImmutableList<AvatarAction>,
val createRoomAction: Async<RoomId>,
val eventSink: (ConfigureRoomEvents) -> Unit
)
) {
val isCreateButtonEnabled: Boolean = config.roomName.isNullOrEmpty().not()
}

View file

@ -18,8 +18,9 @@ package io.element.android.features.createroom.impl.configureroom
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.features.userlist.api.aListOfSelectedUsers
import io.element.android.features.createroom.impl.userlist.aListOfSelectedUsers
import io.element.android.libraries.architecture.Async
import kotlinx.collections.immutable.persistentListOf
open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomState> {
override val values: Sequence<ConfigureRoomState>
@ -30,16 +31,15 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomSt
roomName = "Room 101",
topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines",
invites = aListOfSelectedUsers(),
privacy = RoomPrivacy.Private,
privacy = RoomPrivacy.Public,
),
isCreateButtonEnabled = true,
),
)
}
fun aConfigureRoomState() = ConfigureRoomState(
config = CreateRoomConfig(),
isCreateButtonEnabled = false,
avatarActions = persistentListOf(),
createRoomAction = Async.Uninitialized,
eventSink = {}
eventSink = { },
)

View file

@ -14,37 +14,42 @@
* limitations under the License.
*/
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.features.createroom.impl.configureroom
import android.net.Uri
import android.widget.Toast
import androidx.compose.foundation.gestures.detectTapGestures
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.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.rememberModalBottomSheetState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.focus.FocusManager
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalFocusManager
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 androidx.core.net.toUri
import io.element.android.features.createroom.impl.R
import io.element.android.features.createroom.impl.components.Avatar
import io.element.android.features.createroom.impl.components.LabelledTextField
import io.element.android.features.createroom.impl.components.RoomPrivacyOption
import io.element.android.features.userlist.api.components.SelectedUsersList
import io.element.android.features.createroom.impl.configureroom.avatar.AvatarActionListView
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.button.BackButton
@ -56,8 +61,11 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.components.SelectedUsersList
import kotlinx.coroutines.launch
import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterialApi::class)
@Composable
fun ConfigureRoomView(
state: ConfigureRoomState,
@ -65,59 +73,96 @@ fun ConfigureRoomView(
onBackPressed: () -> Unit = {},
onRoomCreated: (RoomId) -> Unit = {},
) {
val coroutineScope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current
val itemActionsBottomSheetState = rememberModalBottomSheetState(
initialValue = ModalBottomSheetValue.Hidden,
)
if (state.createRoomAction is Async.Success) {
LaunchedEffect(state.createRoomAction) {
onRoomCreated(state.createRoomAction.state)
}
}
val context = LocalContext.current
fun onAvatarClicked() {
focusManager.clearFocus()
coroutineScope.launch {
itemActionsBottomSheetState.show()
}
}
Scaffold(
modifier = modifier,
modifier = modifier.clearFocusOnTap(focusManager),
topBar = {
ConfigureRoomToolbar(
isNextActionEnabled = state.isCreateButtonEnabled,
onBackPressed = onBackPressed,
onNextPressed = {
focusManager.clearFocus()
state.eventSink(ConfigureRoomEvents.CreateRoom(state.config))
},
)
}
) { padding ->
Column(
modifier = Modifier.padding(padding),
LazyColumn(
modifier = Modifier
.padding(padding)
.consumeWindowInsets(padding),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
RoomNameWithAvatar(
modifier = Modifier.padding(horizontal = 16.dp),
avatarUri = state.config.avatarUrl?.toUri(),
roomName = state.config.roomName.orEmpty(),
onAvatarClick = { Toast.makeText(context, "not implemented yet", Toast.LENGTH_SHORT).show() },
onRoomNameChanged = { state.eventSink(ConfigureRoomEvents.RoomNameChanged(it)) },
)
RoomTopic(
modifier = Modifier.padding(horizontal = 16.dp),
topic = state.config.topic.orEmpty(),
onTopicChanged = { state.eventSink(ConfigureRoomEvents.TopicChanged(it)) },
)
SelectedUsersList(
contentPadding = PaddingValues(horizontal = 24.dp),
selectedUsers = state.config.invites,
onUserRemoved = { state.eventSink(ConfigureRoomEvents.RemoveFromSelection(it)) },
)
Spacer(Modifier.weight(1f))
RoomPrivacyOptions(
modifier = Modifier.padding(bottom = 40.dp),
selected = state.config.privacy,
onOptionSelected = { state.eventSink(ConfigureRoomEvents.RoomPrivacyChanged(it.privacy)) },
)
item {
RoomNameWithAvatar(
modifier = Modifier.padding(horizontal = 16.dp),
avatarUri = state.config.avatarUri,
roomName = state.config.roomName.orEmpty(),
onAvatarClick = ::onAvatarClicked,
onRoomNameChanged = { state.eventSink(ConfigureRoomEvents.RoomNameChanged(it)) },
)
}
item {
RoomTopic(
modifier = Modifier.padding(horizontal = 16.dp),
topic = state.config.topic.orEmpty(),
onTopicChanged = { state.eventSink(ConfigureRoomEvents.TopicChanged(it)) },
)
}
if (state.config.invites.isNotEmpty()) {
item {
SelectedUsersList(
contentPadding = PaddingValues(horizontal = 24.dp),
selectedUsers = state.config.invites,
onUserRemoved = {
focusManager.clearFocus()
state.eventSink(ConfigureRoomEvents.RemoveFromSelection(it))
},
)
}
}
item {
RoomPrivacyOptions(
modifier = Modifier.padding(bottom = 40.dp),
selected = state.config.privacy,
onOptionSelected = {
focusManager.clearFocus()
state.eventSink(ConfigureRoomEvents.RoomPrivacyChanged(it.privacy))
},
)
}
}
}
AvatarActionListView(
actions = state.avatarActions,
modalBottomSheetState = itemActionsBottomSheetState,
onActionSelected = { state.eventSink(ConfigureRoomEvents.HandleAvatarAction(it)) }
)
when (state.createRoomAction) {
is Async.Loading -> {
ProgressDialog(text = stringResource(StringR.string.common_creating_room))
}
is Async.Failure -> {
RetryDialog(
content = stringResource(R.string.screen_create_room_error_creating_room),
@ -125,10 +170,12 @@ fun ConfigureRoomView(
onRetry = { state.eventSink(ConfigureRoomEvents.CreateRoom(state.config)) },
)
}
else -> Unit
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConfigureRoomToolbar(
isNextActionEnabled: Boolean,
@ -238,3 +285,11 @@ private fun ContentToPreview(state: ConfigureRoomState) {
state = state,
)
}
private fun Modifier.clearFocusOnTap(focusManager: FocusManager): Modifier =
pointerInput(Unit) {
detectTapGestures(onTap = {
focusManager.clearFocus()
})
}

View file

@ -0,0 +1,37 @@
/*
* 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.createroom.impl.configureroom.avatar
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.PhotoCamera
import androidx.compose.material.icons.outlined.PhotoLibrary
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.vector.ImageVector
import io.element.android.libraries.ui.strings.R
@Immutable
sealed class AvatarAction(
@StringRes val titleResId: Int,
val icon: ImageVector,
val destructive: Boolean = false,
) {
object TakePhoto : AvatarAction(titleResId = R.string.action_take_photo, icon = Icons.Outlined.PhotoCamera)
object ChoosePhoto : AvatarAction(titleResId = R.string.action_choose_photo, icon = Icons.Outlined.PhotoLibrary)
object Remove : AvatarAction(titleResId = R.string.action_remove, icon = Icons.Outlined.Delete, destructive = true)
}

View file

@ -0,0 +1,126 @@
/*
* 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.
*/
@file:OptIn(ExperimentalMaterialApi::class)
package io.element.android.features.createroom.impl.configureroom.avatar
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ModalBottomSheetState
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.Text
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheetLayout
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.launch
@Composable
fun AvatarActionListView(
actions: ImmutableList<AvatarAction>,
modalBottomSheetState: ModalBottomSheetState,
modifier: Modifier = Modifier,
onActionSelected: (action: AvatarAction) -> Unit = {},
) {
val coroutineScope = rememberCoroutineScope()
fun onItemActionClicked(itemAction: AvatarAction) {
onActionSelected(itemAction)
coroutineScope.launch {
modalBottomSheetState.hide()
}
}
ModalBottomSheetLayout(
modifier = modifier,
sheetState = modalBottomSheetState,
sheetContent = {
SheetContent(
actions = actions,
onActionClicked = ::onItemActionClicked,
modifier = Modifier
.navigationBarsPadding()
.imePadding()
)
}
)
}
@Composable
private fun SheetContent(
actions: ImmutableList<AvatarAction>,
modifier: Modifier = Modifier,
onActionClicked: (AvatarAction) -> Unit = { },
) {
LazyColumn(
modifier = modifier.fillMaxWidth()
) {
items(
items = actions,
) { action ->
ListItem(
modifier = Modifier.clickable { onActionClicked(action) },
headlineContent = {
Text(
text = stringResource(action.titleResId),
color = if (action.destructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
)
},
leadingContent = {
Icon(
imageVector = action.icon,
contentDescription = stringResource(action.titleResId),
tint = if (action.destructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
)
}
)
}
}
}
@Preview
@Composable
fun SheetContentLightPreview() =
ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
fun SheetContentDarkPreview() =
ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
AvatarActionListView(
actions = persistentListOf(AvatarAction.TakePhoto, AvatarAction.ChoosePhoto, AvatarAction.Remove),
modalBottomSheetState = ModalBottomSheetState(
initialValue = ModalBottomSheetValue.Expanded
),
)
}

View file

@ -21,25 +21,24 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import io.element.android.features.userlist.api.SelectionMode
import io.element.android.features.userlist.api.UserListDataSource
import io.element.android.features.userlist.api.UserListDataStore
import io.element.android.features.userlist.api.UserListPresenter
import io.element.android.features.userlist.api.UserListPresenterArgs
import io.element.android.features.createroom.impl.userlist.SelectionMode
import io.element.android.features.createroom.impl.userlist.UserListDataStore
import io.element.android.features.createroom.impl.userlist.UserListPresenter
import io.element.android.features.createroom.impl.userlist.UserListPresenterArgs
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.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.usersearch.api.UserRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Named
class CreateRoomRootPresenter @Inject constructor(
private val presenterFactory: UserListPresenter.Factory,
@Named("AllUsers") private val userListDataSource: UserListDataSource,
private val userRepository: UserRepository,
private val userListDataStore: UserListDataStore,
private val matrixClient: MatrixClient,
) : Presenter<CreateRoomRootState> {
@ -48,10 +47,8 @@ class CreateRoomRootPresenter @Inject constructor(
presenterFactory.create(
UserListPresenterArgs(
selectionMode = SelectionMode.Single,
minimumSearchLength = 3,
searchDebouncePeriodMillis = UserListPresenterArgs.DEFAULT_DEBOUNCE,
),
userListDataSource,
userRepository,
userListDataStore,
)
}

View file

@ -16,7 +16,7 @@
package io.element.android.features.createroom.impl.root
import io.element.android.features.userlist.api.UserListState
import io.element.android.features.createroom.impl.userlist.UserListState
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.RoomId

View file

@ -17,9 +17,10 @@
package io.element.android.features.createroom.impl.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.userlist.api.UserSearchResultState
import io.element.android.features.userlist.api.aUserListState
import io.element.android.features.createroom.impl.userlist.aUserListState
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import kotlinx.collections.immutable.persistentListOf
@ -32,7 +33,7 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRoot
userListState = aMatrixUser().let {
aUserListState().copy(
searchQuery = it.userId.value,
searchResults = UserSearchResultState.Results(persistentListOf(it)),
searchResults = SearchBarResultState.Results(persistentListOf(it)),
selectedUsers = persistentListOf(it),
isSearchActive = true,
)
@ -43,7 +44,7 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRoot
userListState = aMatrixUser().let {
aUserListState().copy(
searchQuery = it.userId.value,
searchResults = UserSearchResultState.Results(persistentListOf(it)),
searchResults = SearchBarResultState.Results(persistentListOf(it)),
selectedUsers = persistentListOf(it),
isSearchActive = true,
)

View file

@ -20,7 +20,9 @@ import androidx.annotation.DrawableRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@ -32,7 +34,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
@ -40,7 +41,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
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.userlist.api.components.UserListView
import io.element.android.features.createroom.impl.components.UserListView
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
@ -55,7 +56,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.designsystem.R as DrawableR
import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CreateRoomRootView(
state: CreateRoomRootState,
@ -79,10 +80,11 @@ fun CreateRoomRootView(
}
) { paddingValues ->
Column(
modifier = Modifier.padding(paddingValues),
modifier = Modifier
.padding(paddingValues)
.consumeWindowInsets(paddingValues),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
val context = LocalContext.current
UserListView(
modifier = Modifier.fillMaxWidth(),
state = state.userListState,
@ -102,7 +104,7 @@ fun CreateRoomRootView(
when (state.startDmAction) {
is Async.Loading -> {
ProgressDialog(text = stringResource(id = StringR.string.common_creating_room))
ProgressDialog(text = stringResource(id = StringR.string.common_starting_chat))
}
is Async.Failure -> {

View file

@ -0,0 +1,90 @@
/*
* 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.createroom.impl.userlist
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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 com.squareup.anvil.annotations.ContributesBinding
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.di.SessionScope
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.toImmutableList
class DefaultUserListPresenter @AssistedInject constructor(
@Assisted val args: UserListPresenterArgs,
@Assisted val userRepository: UserRepository,
@Assisted val userListDataStore: UserListDataStore,
) : UserListPresenter {
@AssistedFactory
@ContributesBinding(SessionScope::class)
interface DefaultUserListFactory : UserListPresenter.Factory {
override fun create(
args: UserListPresenterArgs,
userRepository: UserRepository,
userListDataStore: UserListDataStore,
): DefaultUserListPresenter
}
@Composable
override fun present(): UserListState {
var isSearchActive by rememberSaveable { mutableStateOf(false) }
val selectedUsers by userListDataStore.selectedUsers().collectAsState(emptyList())
var searchQuery by rememberSaveable { mutableStateOf("") }
var searchResults: SearchBarResultState<ImmutableList<MatrixUser>> by remember {
mutableStateOf(SearchBarResultState.NotSearching())
}
LaunchedEffect(searchQuery) {
searchResults = SearchBarResultState.NotSearching()
userRepository.search(searchQuery).collect {
searchResults = when {
it.isEmpty() -> SearchBarResultState.NoResults()
else -> SearchBarResultState.Results(it.toImmutableList())
}
}
}
return UserListState(
searchQuery = searchQuery,
searchResults = searchResults,
selectedUsers = selectedUsers.toImmutableList(),
isSearchActive = isSearchActive,
selectionMode = args.selectionMode,
eventSink = { event ->
when (event) {
is UserListEvents.OnSearchActiveChanged -> isSearchActive = event.active
is UserListEvents.UpdateSearchQuery -> searchQuery = event.query
is UserListEvents.AddToSelection -> userListDataStore.selectUser(event.matrixUser)
is UserListEvents.RemoveFromSelection -> userListDataStore.removeUserFromSelection(event.matrixUser)
}
},
)
}
}

View file

@ -0,0 +1,39 @@
/*
* 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.createroom.impl.userlist
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import javax.inject.Inject
class UserListDataStore @Inject constructor() {
private val selectedUsers: MutableStateFlow<List<MatrixUser>> = MutableStateFlow(emptyList())
fun selectUser(user: MatrixUser) {
if (user !in selectedUsers.value) {
selectedUsers.tryEmit(selectedUsers.value.plus(user))
}
}
fun removeUserFromSelection(user: MatrixUser) {
selectedUsers.tryEmit(selectedUsers.value.minus(user))
}
fun selectedUsers(): Flow<List<MatrixUser>> = selectedUsers
}

View file

@ -0,0 +1,26 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.createroom.impl.userlist
import io.element.android.libraries.matrix.api.user.MatrixUser
sealed interface UserListEvents {
data class UpdateSearchQuery(val query: String) : UserListEvents
data class AddToSelection(val matrixUser: MatrixUser) : UserListEvents
data class RemoveFromSelection(val matrixUser: MatrixUser) : UserListEvents
data class OnSearchActiveChanged(val active: Boolean) : UserListEvents
}

View file

@ -0,0 +1,31 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.createroom.impl.userlist
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.usersearch.api.UserRepository
interface UserListPresenter : Presenter<UserListState> {
interface Factory {
fun create(
args: UserListPresenterArgs,
userRepository: UserRepository,
userListDataStore: UserListDataStore,
): UserListPresenter
}
}

View file

@ -0,0 +1,26 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.createroom.impl.userlist
data class UserListPresenterArgs(
val selectionMode: SelectionMode,
)
enum class SelectionMode {
Single,
Multiple,
}

View file

@ -0,0 +1,32 @@
/*
* 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.createroom.impl.userlist
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList
data class UserListState(
val searchQuery: String,
val searchResults: SearchBarResultState<ImmutableList<MatrixUser>>,
val selectedUsers: ImmutableList<MatrixUser>,
val isSearchActive: Boolean,
val selectionMode: SelectionMode,
val eventSink: (UserListEvents) -> Unit,
) {
val isMultiSelectionEnabled = selectionMode == SelectionMode.Multiple
}

View file

@ -0,0 +1,67 @@
/*
* 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.createroom.impl.userlist
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
open class UserListStateProvider : PreviewParameterProvider<UserListState> {
override val values: Sequence<UserListState>
get() = sequenceOf(
aUserListState(),
aUserListState().copy(
isSearchActive = false,
selectedUsers = aListOfSelectedUsers(),
selectionMode = SelectionMode.Multiple,
),
aUserListState().copy(isSearchActive = true),
aUserListState().copy(isSearchActive = true, searchQuery = "someone"),
aUserListState().copy(isSearchActive = true, searchQuery = "someone", selectionMode = SelectionMode.Multiple),
aUserListState().copy(
isSearchActive = true,
searchQuery = "@someone:matrix.org",
selectedUsers = aListOfSelectedUsers(),
searchResults = SearchBarResultState.Results(aMatrixUserList().toImmutableList()),
),
aUserListState().copy(
isSearchActive = true,
searchQuery = "@someone:matrix.org",
selectionMode = SelectionMode.Multiple,
selectedUsers = aListOfSelectedUsers(),
searchResults = SearchBarResultState.Results(aMatrixUserList().toImmutableList()),
),
aUserListState().copy(
isSearchActive = true,
searchQuery = "something-with-no-results",
searchResults = SearchBarResultState.NoResults()
),
)
}
fun aUserListState() = UserListState(
isSearchActive = false,
searchQuery = "",
searchResults = SearchBarResultState.NotSearching(),
selectedUsers = persistentListOf(),
selectionMode = SelectionMode.Single,
eventSink = {}
)
fun aListOfSelectedUsers() = aMatrixUserList().take(6).toImmutableList()

View file

@ -1,95 +0,0 @@
/*
* 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.createroom.impl
import com.google.common.truth.Truth
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient
import kotlinx.coroutines.test.runTest
import org.junit.Test
internal class AllMatrixUsersDataSourceTest {
@Test
fun `search - returns users on success`() = runTest {
val matrixClient = FakeMatrixClient()
matrixClient.givenSearchUsersResult(
searchTerm = "test",
result = Result.success(
MatrixSearchUserResults(
results = listOf(aMatrixUserProfile(), aMatrixUserProfile(userId = A_USER_ID_2)),
limited = false
)
)
)
val dataSource = AllMatrixUsersDataSource(matrixClient)
val results = dataSource.search("test")
Truth.assertThat(results).containsExactly(aMatrixUserProfile(), aMatrixUserProfile(userId = A_USER_ID_2))
}
@Test
fun `search - returns empty list on error`() = runTest {
val matrixClient = FakeMatrixClient()
matrixClient.givenSearchUsersResult(
searchTerm = "test",
result = Result.failure(Throwable("Ruhroh"))
)
val dataSource = AllMatrixUsersDataSource(matrixClient)
val results = dataSource.search("test")
Truth.assertThat(results).isEmpty()
}
@Test
fun `get profile - returns user on success`() = runTest {
val matrixClient = FakeMatrixClient()
matrixClient.givenGetProfileResult(
userId = A_USER_ID,
result = Result.success(aMatrixUserProfile())
)
val dataSource = AllMatrixUsersDataSource(matrixClient)
val result = dataSource.getProfile(A_USER_ID)
Truth.assertThat(result).isEqualTo(aMatrixUserProfile())
}
@Test
fun `get profile - returns null on error`() = runTest {
val matrixClient = FakeMatrixClient()
matrixClient.givenGetProfileResult(
userId = A_USER_ID,
result = Result.failure(Throwable("Ruhroh"))
)
val dataSource = AllMatrixUsersDataSource(matrixClient)
val result = dataSource.getProfile(A_USER_ID)
Truth.assertThat(result).isNull()
}
private fun aMatrixUserProfile(
userId: UserId = A_USER_ID,
displayName: String = A_USER_NAME,
avatarUrl: String = AN_AVATAR_URL
) = MatrixUser(userId, displayName, avatarUrl)
}

View file

@ -21,9 +21,9 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.createroom.impl.CreateRoomDataStore
import io.element.android.features.userlist.api.UserListDataStore
import io.element.android.features.userlist.test.FakeUserListDataSource
import io.element.android.features.userlist.test.FakeUserListPresenterFactory
import io.element.android.features.createroom.impl.userlist.FakeUserListPresenterFactory
import io.element.android.features.createroom.impl.userlist.UserListDataStore
import io.element.android.libraries.usersearch.test.FakeUserRepository
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
@ -34,7 +34,11 @@ class AddPeoplePresenterTests {
@Before
fun setup() {
presenter = AddPeoplePresenter(FakeUserListPresenterFactory(), FakeUserListDataSource(), CreateRoomDataStore(UserListDataStore()))
presenter = AddPeoplePresenter(
FakeUserListPresenterFactory(),
FakeUserRepository(),
CreateRoomDataStore(UserListDataStore())
)
}
@Test
@ -42,6 +46,7 @@ class AddPeoplePresenterTests {
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
// TODO This doesn't actually test anything...
val initialState = awaitItem()
assertThat(initialState)
}

View file

@ -23,7 +23,8 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.features.createroom.impl.CreateRoomDataStore
import io.element.android.features.userlist.api.UserListDataStore
import io.element.android.features.createroom.impl.configureroom.avatar.AvatarAction
import io.element.android.features.createroom.impl.userlist.UserListDataStore
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
@ -32,29 +33,57 @@ import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.io.File
private const val AN_URI_FROM_CAMERA = "content://uri_from_camera"
private const val AN_URI_FROM_GALLERY = "content://uri_from_gallery"
@RunWith(RobolectricTestRunner::class)
class ConfigureRoomPresenterTests {
private lateinit var presenter: ConfigureRoomPresenter
private lateinit var userListDataStore: UserListDataStore
private lateinit var createRoomDataStore: CreateRoomDataStore
private lateinit var fakeMatrixClient: FakeMatrixClient
private lateinit var fakePickerProvider: FakePickerProvider
private lateinit var fakeMediaPreProcessor: FakeMediaPreProcessor
@Before
fun setup() {
fakeMatrixClient = FakeMatrixClient()
userListDataStore = UserListDataStore()
createRoomDataStore = CreateRoomDataStore(userListDataStore)
fakePickerProvider = FakePickerProvider()
fakeMediaPreProcessor = FakeMediaPreProcessor()
presenter = ConfigureRoomPresenter(
dataStore = CreateRoomDataStore(userListDataStore),
matrixClient = fakeMatrixClient
dataStore = createRoomDataStore,
matrixClient = fakeMatrixClient,
mediaPickerProvider = fakePickerProvider,
mediaPreProcessor = fakeMediaPreProcessor,
)
mockkStatic(File::readBytes)
every { any<File>().readBytes() } returns byteArrayOf()
}
@After
fun tearDown() {
unmockkAll()
}
@Test
@ -67,8 +96,8 @@ class ConfigureRoomPresenterTests {
assertThat(initialState.config.roomName).isNull()
assertThat(initialState.config.topic).isNull()
assertThat(initialState.config.invites).isEmpty()
assertThat(initialState.config.avatarUrl).isNull()
assertThat(initialState.config.privacy).isNull()
assertThat(initialState.config.avatarUri).isNull()
assertThat(initialState.config.privacy).isEqualTo(RoomPrivacy.Private)
}
}
@ -86,13 +115,6 @@ class ConfigureRoomPresenterTests {
var newState: ConfigureRoomState = awaitItem()
config = config.copy(roomName = A_ROOM_NAME)
assertThat(newState.config).isEqualTo(config)
assertThat(newState.isCreateButtonEnabled).isFalse()
// Select privacy
newState.eventSink(ConfigureRoomEvents.RoomPrivacyChanged(RoomPrivacy.Private))
newState = awaitItem()
config = config.copy(privacy = RoomPrivacy.Private)
assertThat(newState.config).isEqualTo(config)
assertThat(newState.isCreateButtonEnabled).isTrue()
// Clear room name
@ -136,10 +158,28 @@ class ConfigureRoomPresenterTests {
assertThat(newState.config).isEqualTo(expectedConfig)
// Room avatar
val anUri = Uri.parse(AN_AVATAR_URL)
newState.eventSink(ConfigureRoomEvents.AvatarUriChanged(anUri))
// Pick avatar
fakePickerProvider.givenResult(null)
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto))
// From gallery
val uriFromGallery = Uri.parse(AN_URI_FROM_GALLERY)
fakePickerProvider.givenResult(uriFromGallery)
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
newState = awaitItem()
expectedConfig = expectedConfig.copy(avatarUrl = anUri.toString())
expectedConfig = expectedConfig.copy(avatarUri = uriFromGallery)
assertThat(newState.config).isEqualTo(expectedConfig)
// From camera
val uriFromCamera = Uri.parse(AN_URI_FROM_CAMERA)
fakePickerProvider.givenResult(uriFromCamera)
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto))
newState = awaitItem()
expectedConfig = expectedConfig.copy(avatarUri = uriFromCamera)
assertThat(newState.config).isEqualTo(expectedConfig)
// Remove
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.Remove))
newState = awaitItem()
expectedConfig = expectedConfig.copy(avatarUri = null)
assertThat(newState.config).isEqualTo(expectedConfig)
// Room privacy
@ -174,6 +214,30 @@ class ConfigureRoomPresenterTests {
}
}
@Test
fun `present - trigger create room with upload error and retry`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
createRoomDataStore.setAvatarUri(Uri.parse(AN_URI_FROM_GALLERY))
fakeMediaPreProcessor.givenResult(Result.success(MediaUploadInfo.Image(mockk(), mockk(), mockk())))
fakeMatrixClient.givenUploadMediaResult(Result.failure(A_THROWABLE))
val initialState = awaitItem()
initialState.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config))
val stateAfterCreateRoom = awaitItem()
assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(Async.Failure::class.java)
fakeMatrixClient.givenUploadMediaResult(Result.success(AN_AVATAR_URL))
stateAfterCreateRoom.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config))
assertThat(awaitItem().createRoomAction).isInstanceOf(Async.Uninitialized::class.java)
assertThat(awaitItem().createRoomAction).isInstanceOf(Async.Loading::class.java)
assertThat(awaitItem().createRoomAction).isInstanceOf(Async.Success::class.java)
}
}
@Test
fun `present - trigger retry and cancel actions`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {

View file

@ -20,11 +20,10 @@ 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.userlist.api.UserListDataStore
import io.element.android.features.userlist.api.aUserListState
import io.element.android.features.userlist.test.FakeUserListDataSource
import io.element.android.features.userlist.test.FakeUserListPresenter
import io.element.android.features.userlist.test.FakeUserListPresenterFactory
import io.element.android.features.createroom.impl.userlist.FakeUserListPresenter
import io.element.android.features.createroom.impl.userlist.FakeUserListPresenterFactory
import io.element.android.features.createroom.impl.userlist.UserListDataStore
import io.element.android.features.createroom.impl.userlist.aUserListState
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
@ -32,6 +31,7 @@ import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.usersearch.test.FakeUserRepository
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.runTest
import org.junit.Before
@ -39,7 +39,7 @@ import org.junit.Test
class CreateRoomRootPresenterTests {
private lateinit var userListDataSource: FakeUserListDataSource
private lateinit var userRepository: FakeUserRepository
private lateinit var presenter: CreateRoomRootPresenter
private lateinit var fakeUserListPresenter: FakeUserListPresenter
private lateinit var fakeMatrixClient: FakeMatrixClient
@ -48,8 +48,8 @@ class CreateRoomRootPresenterTests {
fun setup() {
fakeUserListPresenter = FakeUserListPresenter()
fakeMatrixClient = FakeMatrixClient()
userListDataSource = FakeUserListDataSource()
presenter = CreateRoomRootPresenter(FakeUserListPresenterFactory(fakeUserListPresenter), userListDataSource, UserListDataStore(), fakeMatrixClient)
userRepository = FakeUserRepository()
presenter = CreateRoomRootPresenter(FakeUserListPresenterFactory(fakeUserListPresenter), userRepository, UserListDataStore(), fakeMatrixClient)
}
@Test

View file

@ -0,0 +1,215 @@
/*
* 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.createroom.impl.userlist
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.libraries.designsystem.theme.components.SearchBarResultState
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 kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.runTest
import org.junit.Test
class DefaultUserListPresenterTests {
private val userRepository = FakeUserRepository()
@Test
fun `present - initial state for single selection`() = runTest {
val presenter =
DefaultUserListPresenter(
UserListPresenterArgs(selectionMode = SelectionMode.Single),
userRepository,
UserListDataStore(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.searchQuery).isEmpty()
assertThat(initialState.isMultiSelectionEnabled).isFalse()
assertThat(initialState.isSearchActive).isFalse()
assertThat(initialState.selectedUsers).isEmpty()
assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.NotSearching::class.java)
}
}
@Test
fun `present - initial state for multiple selection`() = runTest {
val presenter =
DefaultUserListPresenter(
UserListPresenterArgs(selectionMode = SelectionMode.Multiple),
userRepository,
UserListDataStore(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.searchQuery).isEmpty()
assertThat(initialState.isMultiSelectionEnabled).isTrue()
assertThat(initialState.isSearchActive).isFalse()
assertThat(initialState.selectedUsers).isEmpty()
assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.NotSearching::class.java)
}
}
@Test
fun `present - update search query`() = runTest {
val presenter =
DefaultUserListPresenter(
UserListPresenterArgs(selectionMode = SelectionMode.Single),
userRepository,
UserListDataStore(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink(UserListEvents.OnSearchActiveChanged(true))
assertThat(awaitItem().isSearchActive).isTrue()
val matrixIdQuery = "@name:matrix.org"
initialState.eventSink(UserListEvents.UpdateSearchQuery(matrixIdQuery))
assertThat(awaitItem().searchQuery).isEqualTo(matrixIdQuery)
assertThat(userRepository.providedQuery).isEqualTo(matrixIdQuery)
skipItems(1)
val notMatrixIdQuery = "name"
initialState.eventSink(UserListEvents.UpdateSearchQuery(notMatrixIdQuery))
assertThat(awaitItem().searchQuery).isEqualTo(notMatrixIdQuery)
assertThat(userRepository.providedQuery).isEqualTo(notMatrixIdQuery)
skipItems(1)
initialState.eventSink(UserListEvents.OnSearchActiveChanged(false))
assertThat(awaitItem().isSearchActive).isFalse()
}
}
@Test
fun `present - presents search results`() = runTest {
val presenter =
DefaultUserListPresenter(
UserListPresenterArgs(
selectionMode = SelectionMode.Single,
),
userRepository,
UserListDataStore(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink(UserListEvents.UpdateSearchQuery("alice"))
assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.NotSearching::class.java)
assertThat(userRepository.providedQuery).isEqualTo("alice")
skipItems(2)
// When the user repository emits a result, it's copied to the state
userRepository.emitResult(listOf(aMatrixUser()))
assertThat(awaitItem().searchResults).isEqualTo(
SearchBarResultState.Results(
persistentListOf(aMatrixUser())
)
)
// When the user repository emits another result, it replaces the previous value
userRepository.emitResult(aMatrixUserList())
assertThat(awaitItem().searchResults).isEqualTo(
SearchBarResultState.Results(
aMatrixUserList()
)
)
}
}
@Test
fun `present - presents search results when not found`() = runTest {
val presenter =
DefaultUserListPresenter(
UserListPresenterArgs(
selectionMode = SelectionMode.Single,
),
userRepository,
UserListDataStore(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink(UserListEvents.UpdateSearchQuery("alice"))
assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.NotSearching::class.java)
assertThat(userRepository.providedQuery).isEqualTo("alice")
skipItems(2)
// When the results list is empty, the state is set to NoResults
userRepository.emitResult(emptyList())
assertThat(awaitItem().searchResults).isInstanceOf(SearchBarResultState.NoResults::class.java)
}
}
@Test
fun `present - select a user`() = runTest {
val presenter =
DefaultUserListPresenter(
UserListPresenterArgs(selectionMode = SelectionMode.Single),
userRepository,
UserListDataStore(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
val userA = aMatrixUser("@userA:domain", "A")
val userB = aMatrixUser("@userB:domain", "B")
val userABis = aMatrixUser("@userA:domain", "A")
val userC = aMatrixUser("@userC:domain", "C")
initialState.eventSink(UserListEvents.AddToSelection(userA))
assertThat(awaitItem().selectedUsers).containsExactly(userA)
initialState.eventSink(UserListEvents.AddToSelection(userB))
assertThat(awaitItem().selectedUsers).containsExactly(userA, userB)
initialState.eventSink(UserListEvents.AddToSelection(userABis))
initialState.eventSink(UserListEvents.AddToSelection(userC))
// duplicated users should be ignored
assertThat(awaitItem().selectedUsers).containsExactly(userA, userB, userC)
initialState.eventSink(UserListEvents.RemoveFromSelection(userB))
assertThat(awaitItem().selectedUsers).containsExactly(userA, userC)
initialState.eventSink(UserListEvents.RemoveFromSelection(userA))
assertThat(awaitItem().selectedUsers).containsExactly(userC)
initialState.eventSink(UserListEvents.RemoveFromSelection(userC))
assertThat(awaitItem().selectedUsers).isEmpty()
}
}
}

View file

@ -14,22 +14,20 @@
* limitations under the License.
*/
package io.element.android.features.createroom.impl.di
package io.element.android.features.createroom.impl.userlist
import com.squareup.anvil.annotations.ContributesTo
import dagger.Binds
import dagger.Module
import io.element.android.features.createroom.impl.AllMatrixUsersDataSource
import io.element.android.features.userlist.api.UserListDataSource
import io.element.android.libraries.di.SessionScope
import javax.inject.Named
import androidx.compose.runtime.Composable
@Module
@ContributesTo(SessionScope::class)
interface CreateRoomModule {
class FakeUserListPresenter : UserListPresenter {
@Binds
@Named("AllUsers")
fun bindAllUserListDataSource(dataSource: AllMatrixUsersDataSource): UserListDataSource
private var state = aUserListState()
fun givenState(state: UserListState) {
this.state = state
}
@Composable
override fun present(): UserListState {
return state
}
}

View file

@ -0,0 +1,30 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.createroom.impl.userlist
import io.element.android.libraries.usersearch.api.UserRepository
class FakeUserListPresenterFactory(
private val fakeUserListPresenter: FakeUserListPresenter = FakeUserListPresenter()
) : UserListPresenter.Factory {
override fun create(
args: UserListPresenterArgs,
userRepository: UserRepository,
userListDataStore: UserListDataStore,
): UserListPresenter = fakeUserListPresenter
}