Merge pull request #408 from vector-im/feature/fre/create_room_select_avatar
Create Room - Pick and upload avatar
This commit is contained in:
1
changelog.d/122.feature
Normal file
1
changelog.d/122.feature
Normal file
@@ -0,0 +1 @@
|
||||
[Create and join rooms] Select a media from the camera
|
||||
1
changelog.d/123.feature
Normal file
1
changelog.d/123.feature
Normal file
@@ -0,0 +1 @@
|
||||
[Create and join rooms] Select a media from the gallery
|
||||
@@ -46,10 +46,13 @@ dependencies {
|
||||
implementation(projects.libraries.elementresources)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
implementation(projects.features.userlist.api)
|
||||
implementation(projects.libraries.mediapickers.api)
|
||||
implementation(projects.libraries.mediaupload.api)
|
||||
implementation(libs.coil.compose)
|
||||
api(projects.features.createroom.api)
|
||||
implementation(libs.coil.compose) // FIXME temp
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.test.mockk)
|
||||
testImplementation(libs.coroutines.test)
|
||||
testImplementation(libs.molecule.runtime)
|
||||
testImplementation(libs.test.truth)
|
||||
@@ -58,6 +61,8 @@ dependencies {
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.features.userlist.impl)
|
||||
testImplementation(projects.features.userlist.test)
|
||||
testImplementation(projects.libraries.mediapickers.test)
|
||||
testImplementation(projects.libraries.mediaupload.test)
|
||||
|
||||
androidTestImplementation(libs.test.junitext)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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.features.createroom.impl.di.CreateRoomScope
|
||||
import io.element.android.features.userlist.api.UserListDataStore
|
||||
@@ -24,6 +25,7 @@ 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?) {
|
||||
createRoomConfigFlow.tryEmit(createRoomConfigFlow.value.copy(privacy = privacy))
|
||||
}
|
||||
|
||||
fun clearCachedData() {
|
||||
cachedAvatarUri = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 RemoveFromSelection(val matrixUser: MatrixUser) : ConfigureRoomEvents
|
||||
data class CreateRoom(val config: CreateRoomConfig) : ConfigureRoomEvents
|
||||
data class HandleAvatarAction(val action: AvatarAction) : ConfigureRoomEvents
|
||||
object CancelCreateRoom : ConfigureRoomEvents
|
||||
}
|
||||
|
||||
@@ -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,21 @@ 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 io.element.android.libraries.mediaupload.api.MediaType
|
||||
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
@@ -41,6 +49,8 @@ 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
|
||||
@@ -52,6 +62,23 @@ class ConfigureRoomPresenter @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
listOfNotNull(
|
||||
AvatarAction.TakePhoto,
|
||||
AvatarAction.ChoosePhoto,
|
||||
AvatarAction.Remove.takeIf { createRoomConfig.value.avatarUri != null },
|
||||
).toImmutableList()
|
||||
}
|
||||
}
|
||||
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
val createRoomAction: MutableState<Async<RoomId>> = remember { mutableStateOf(Async.Uninitialized) }
|
||||
|
||||
@@ -62,12 +89,19 @@ 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
|
||||
}
|
||||
}
|
||||
@@ -75,13 +109,18 @@ class ConfigureRoomPresenter @Inject constructor(
|
||||
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 mxc = config.avatarUri?.let { uploadAvatar(it) }
|
||||
val params = CreateRoomParameters(
|
||||
name = config.roomName,
|
||||
topic = config.topic,
|
||||
@@ -90,9 +129,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 = mxc,
|
||||
)
|
||||
matrixClient.createRoom(params).getOrThrow()
|
||||
.also { dataStore.clearCachedData() }
|
||||
}.execute(createRoomAction)
|
||||
}
|
||||
|
||||
private suspend fun uploadAvatar(avatarUri: Uri): String? {
|
||||
val preprocessed = mediaPreProcessor.process(avatarUri, MediaType.Image).getOrThrow() as? MediaUploadInfo.Image
|
||||
val byteArray = preprocessed?.file?.readBytes()
|
||||
return byteArray?.let { matrixClient.uploadMedia(MimeTypes.Jpeg, it) }?.getOrThrow()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,12 +17,15 @@
|
||||
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
|
||||
)
|
||||
|
||||
@@ -20,6 +20,7 @@ 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.libraries.architecture.Async
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomState> {
|
||||
override val values: Sequence<ConfigureRoomState>
|
||||
@@ -40,6 +41,7 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomSt
|
||||
fun aConfigureRoomState() = ConfigureRoomState(
|
||||
config = CreateRoomConfig(),
|
||||
isCreateButtonEnabled = false,
|
||||
avatarActions = persistentListOf(),
|
||||
createRoomAction = Async.Uninitialized,
|
||||
eventSink = {}
|
||||
eventSink = { },
|
||||
)
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
package io.element.android.features.createroom.impl.configureroom
|
||||
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
@@ -25,23 +24,26 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
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.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.createroom.impl.configureroom.avatar.AvatarActionListView
|
||||
import io.element.android.features.userlist.api.components.SelectedUsersList
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.designsystem.components.ProgressDialog
|
||||
@@ -54,8 +56,10 @@ 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 kotlinx.coroutines.launch
|
||||
import io.element.android.libraries.ui.strings.R as StringR
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun ConfigureRoomView(
|
||||
state: ConfigureRoomState,
|
||||
@@ -63,13 +67,23 @@ fun ConfigureRoomView(
|
||||
onBackPressed: () -> Unit = {},
|
||||
onRoomCreated: (RoomId) -> Unit = {},
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
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() {
|
||||
coroutineScope.launch {
|
||||
itemActionsBottomSheetState.show()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
@@ -88,9 +102,9 @@ fun ConfigureRoomView(
|
||||
) {
|
||||
RoomNameWithAvatar(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
avatarUri = state.config.avatarUrl?.toUri(),
|
||||
avatarUri = state.config.avatarUri,
|
||||
roomName = state.config.roomName.orEmpty(),
|
||||
onAvatarClick = { Toast.makeText(context, "not implemented yet", Toast.LENGTH_SHORT).show() },
|
||||
onAvatarClick = ::onAvatarClicked,
|
||||
onRoomNameChanged = { state.eventSink(ConfigureRoomEvents.RoomNameChanged(it)) },
|
||||
)
|
||||
RoomTopic(
|
||||
@@ -112,10 +126,17 @@ fun ConfigureRoomView(
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
@@ -123,6 +144,7 @@ fun ConfigureRoomView(
|
||||
onRetry = { state.eventSink(ConfigureRoomEvents.CreateRoom(state.config)) },
|
||||
)
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -23,6 +23,7 @@ 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.createroom.impl.configureroom.avatar.AvatarAction
|
||||
import io.element.android.features.userlist.api.UserListDataStore
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
@@ -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,7 +96,7 @@ 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.avatarUri).isNull()
|
||||
assertThat(initialState.config.privacy).isNull()
|
||||
}
|
||||
}
|
||||
@@ -136,10 +165,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 +221,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) {
|
||||
|
||||
@@ -42,6 +42,7 @@ interface MatrixClient : Closeable {
|
||||
suspend fun createRoom(createRoomParams: CreateRoomParameters): Result<RoomId>
|
||||
suspend fun createDM(userId: UserId): Result<RoomId>
|
||||
suspend fun getProfile(userId: UserId): Result<MatrixUser>
|
||||
suspend fun searchUsers(searchTerm: String, limit: Long): Result<MatrixSearchUserResults>
|
||||
fun startSync()
|
||||
fun stopSync()
|
||||
fun mediaResolver(): MediaResolver
|
||||
@@ -58,9 +59,10 @@ interface MatrixClient : Closeable {
|
||||
height: Long
|
||||
): Result<ByteArray>
|
||||
|
||||
suspend fun uploadMedia(mimeType: String, data: ByteArray): Result<String>
|
||||
|
||||
fun onSlidingSyncUpdate()
|
||||
|
||||
fun roomMembershipObserver(): RoomMembershipObserver
|
||||
|
||||
suspend fun searchUsers(searchTerm: String, limit: Long): Result<MatrixSearchUserResults>
|
||||
}
|
||||
|
||||
@@ -279,6 +279,13 @@ class RustMatrixClient constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun searchUsers(searchTerm: String, limit: Long): Result<MatrixSearchUserResults> =
|
||||
withContext(dispatchers.io) {
|
||||
runCatching {
|
||||
client.searchUsers(searchTerm, limit.toULong()).let(UserSearchResultMapper::map)
|
||||
}
|
||||
}
|
||||
|
||||
override fun mediaResolver(): MediaResolver = mediaResolver
|
||||
|
||||
override fun sessionVerificationService(): SessionVerificationService = verificationService
|
||||
@@ -366,6 +373,13 @@ class RustMatrixClient constructor(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalUnsignedTypes::class)
|
||||
override suspend fun uploadMedia(mimeType: String, data: ByteArray): Result<String> = withContext(dispatchers.io) {
|
||||
runCatching {
|
||||
client.uploadMedia(mimeType, data.toUByteArray().toList())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSlidingSyncUpdate() {
|
||||
if (!verificationService.isReady.value) {
|
||||
try {
|
||||
@@ -378,13 +392,6 @@ class RustMatrixClient constructor(
|
||||
|
||||
override fun roomMembershipObserver(): RoomMembershipObserver = roomMembershipObserver
|
||||
|
||||
override suspend fun searchUsers(searchTerm: String, limit: Long): Result<MatrixSearchUserResults> =
|
||||
withContext(dispatchers.io) {
|
||||
runCatching {
|
||||
client.searchUsers(searchTerm, limit.toULong()).let(UserSearchResultMapper::map)
|
||||
}
|
||||
}
|
||||
|
||||
private fun File.deleteSessionDirectory(userID: String): Boolean {
|
||||
// Rust sanitises the user ID replacing invalid characters with an _
|
||||
val sanitisedUserID = userID.replace(":", "_")
|
||||
|
||||
@@ -59,6 +59,7 @@ class FakeMatrixClient(
|
||||
private val getRoomResults = mutableMapOf<RoomId, MatrixRoom>()
|
||||
private val searchUserResults = mutableMapOf<String, Result<MatrixSearchUserResults>>()
|
||||
private val getProfileResults = mutableMapOf<UserId, Result<MatrixUser>>()
|
||||
private var uploadMediaResult: Result<String> = Result.success(AN_AVATAR_URL)
|
||||
|
||||
override fun getRoom(roomId: RoomId): MatrixRoom? {
|
||||
return getRoomResults[roomId]
|
||||
@@ -91,6 +92,10 @@ class FakeMatrixClient(
|
||||
return getProfileResults[userId] ?: Result.failure(IllegalStateException("No profile found for $userId"))
|
||||
}
|
||||
|
||||
override suspend fun searchUsers(searchTerm: String, limit: Long): Result<MatrixSearchUserResults> {
|
||||
return searchUserResults[searchTerm] ?: Result.failure(IllegalStateException("No response defined for $searchTerm"))
|
||||
}
|
||||
|
||||
override fun startSync() = Unit
|
||||
|
||||
override fun stopSync() = Unit
|
||||
@@ -122,6 +127,10 @@ class FakeMatrixClient(
|
||||
return Result.success(ByteArray(0))
|
||||
}
|
||||
|
||||
override suspend fun uploadMedia(mimeType: String, data: ByteArray): Result<String> {
|
||||
return uploadMediaResult
|
||||
}
|
||||
|
||||
override fun sessionVerificationService(): SessionVerificationService = sessionVerificationService
|
||||
|
||||
override fun pushersService(): PushersService = pushersService
|
||||
@@ -134,10 +143,6 @@ class FakeMatrixClient(
|
||||
return RoomMembershipObserver()
|
||||
}
|
||||
|
||||
override suspend fun searchUsers(searchTerm: String, limit: Long): Result<MatrixSearchUserResults> {
|
||||
return searchUserResults[searchTerm] ?: Result.failure(IllegalStateException("No response defined for $searchTerm"))
|
||||
}
|
||||
|
||||
// Mocks
|
||||
|
||||
fun givenLogoutError(failure: Throwable?) {
|
||||
@@ -179,4 +184,8 @@ class FakeMatrixClient(
|
||||
fun givenGetProfileResult(userId: UserId, result: Result<MatrixUser>) {
|
||||
getProfileResults[userId] = result
|
||||
}
|
||||
|
||||
fun givenUploadMediaResult(result: Result<String>) {
|
||||
uploadMediaResult = result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,11 @@ interface PickerProvider {
|
||||
onResult: (uri: Uri?, mimeType: String?) -> Unit
|
||||
): PickerLauncher<PickVisualMediaRequest, Uri?>
|
||||
|
||||
@Composable
|
||||
fun registerGalleryImagePicker(
|
||||
onResult: (Uri?) -> Unit
|
||||
): PickerLauncher<PickVisualMediaRequest, Uri?>
|
||||
|
||||
@Composable
|
||||
fun registerFilePicker(
|
||||
mimeType: String,
|
||||
@@ -38,5 +43,4 @@ interface PickerProvider {
|
||||
|
||||
@Composable
|
||||
fun registerCameraVideoPicker(onResult: (Uri?) -> Unit): PickerLauncher<Uri, Boolean>
|
||||
|
||||
}
|
||||
|
||||
@@ -26,6 +26,13 @@ sealed interface PickerType<Input, Output> {
|
||||
fun getContract(): ActivityResultContract<Input, Output>
|
||||
fun getDefaultRequest(): Input
|
||||
|
||||
object Image : PickerType<PickVisualMediaRequest, Uri?> {
|
||||
override fun getContract() = ActivityResultContracts.PickVisualMedia()
|
||||
override fun getDefaultRequest(): PickVisualMediaRequest {
|
||||
return PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
|
||||
}
|
||||
}
|
||||
|
||||
object ImageAndVideo : PickerType<PickVisualMediaRequest, Uri?> {
|
||||
override fun getContract() = ActivityResultContracts.PickVisualMedia()
|
||||
override fun getDefaultRequest(): PickVisualMediaRequest {
|
||||
|
||||
@@ -59,6 +59,20 @@ class PickerProviderImpl constructor(private val isInTest: Boolean) : PickerProv
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remembers and returns a [PickerLauncher] for a gallery picture.
|
||||
* [onResult] will be called with either the selected file's [Uri] or `null` if nothing was selected.
|
||||
*/
|
||||
@Composable
|
||||
override fun registerGalleryImagePicker(onResult: (Uri?) -> Unit): PickerLauncher<PickVisualMediaRequest, Uri?> {
|
||||
// Tests and UI preview can't handle Contexts, so we might as well disable the whole picker
|
||||
return if (LocalInspectionMode.current || isInTest) {
|
||||
NoOpPickerLauncher { onResult(null) }
|
||||
} else {
|
||||
rememberPickerLauncher(type = PickerType.Image) { uri -> onResult(uri) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remembers and returns a [PickerLauncher] for a gallery item, either a picture or a video.
|
||||
* [onResult] will be called with either the selected file's [Uri] or `null` if nothing was selected.
|
||||
|
||||
@@ -33,6 +33,11 @@ class FakePickerProvider : PickerProvider {
|
||||
return NoOpPickerLauncher { onResult(result, mimeType) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun registerGalleryImagePicker(onResult: (uri: Uri?) -> Unit): PickerLauncher<PickVisualMediaRequest, Uri?> {
|
||||
return NoOpPickerLauncher { onResult(result) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun registerFilePicker(mimeType: String, onResult: (Uri?) -> Unit): PickerLauncher<String, Uri?> {
|
||||
return NoOpPickerLauncher { onResult(result) }
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user