Merge pull request #408 from vector-im/feature/fre/create_room_select_avatar
Create Room - Pick and upload avatar
This commit is contained in:
commit
ac0635421b
22 changed files with 416 additions and 35 deletions
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.elementresources)
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
implementation(projects.features.userlist.api)
|
implementation(projects.features.userlist.api)
|
||||||
|
implementation(projects.libraries.mediapickers.api)
|
||||||
|
implementation(projects.libraries.mediaupload.api)
|
||||||
|
implementation(libs.coil.compose)
|
||||||
api(projects.features.createroom.api)
|
api(projects.features.createroom.api)
|
||||||
implementation(libs.coil.compose) // FIXME temp
|
|
||||||
|
|
||||||
testImplementation(libs.test.junit)
|
testImplementation(libs.test.junit)
|
||||||
|
testImplementation(libs.test.mockk)
|
||||||
testImplementation(libs.coroutines.test)
|
testImplementation(libs.coroutines.test)
|
||||||
testImplementation(libs.molecule.runtime)
|
testImplementation(libs.molecule.runtime)
|
||||||
testImplementation(libs.test.truth)
|
testImplementation(libs.test.truth)
|
||||||
|
|
@ -58,6 +61,8 @@ dependencies {
|
||||||
testImplementation(projects.libraries.matrix.test)
|
testImplementation(projects.libraries.matrix.test)
|
||||||
testImplementation(projects.features.userlist.impl)
|
testImplementation(projects.features.userlist.impl)
|
||||||
testImplementation(projects.features.userlist.test)
|
testImplementation(projects.features.userlist.test)
|
||||||
|
testImplementation(projects.libraries.mediapickers.test)
|
||||||
|
testImplementation(projects.libraries.mediaupload.test)
|
||||||
|
|
||||||
androidTestImplementation(libs.test.junitext)
|
androidTestImplementation(libs.test.junitext)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
|
|
||||||
package io.element.android.features.createroom.impl
|
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.configureroom.RoomPrivacy
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
@ -24,7 +25,7 @@ import kotlinx.collections.immutable.persistentListOf
|
||||||
data class CreateRoomConfig(
|
data class CreateRoomConfig(
|
||||||
val roomName: String? = null,
|
val roomName: String? = null,
|
||||||
val topic: String? = null,
|
val topic: String? = null,
|
||||||
val avatarUrl: String? = null,
|
val avatarUri: Uri? = null,
|
||||||
val invites: ImmutableList<MatrixUser> = persistentListOf(),
|
val invites: ImmutableList<MatrixUser> = persistentListOf(),
|
||||||
val privacy: RoomPrivacy? = null,
|
val privacy: RoomPrivacy? = null,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
|
|
||||||
package io.element.android.features.createroom.impl
|
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.configureroom.RoomPrivacy
|
||||||
import io.element.android.features.createroom.impl.di.CreateRoomScope
|
import io.element.android.features.createroom.impl.di.CreateRoomScope
|
||||||
import io.element.android.features.userlist.api.UserListDataStore
|
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.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import java.io.File
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@SingleIn(CreateRoomScope::class)
|
@SingleIn(CreateRoomScope::class)
|
||||||
|
|
@ -32,6 +34,11 @@ class CreateRoomDataStore @Inject constructor(
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val createRoomConfigFlow: MutableStateFlow<CreateRoomConfig> = MutableStateFlow(CreateRoomConfig())
|
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(
|
fun getCreateRoomConfig(): Flow<CreateRoomConfig> = combine(
|
||||||
selectedUserListDataStore.selectedUsers(),
|
selectedUserListDataStore.selectedUsers(),
|
||||||
|
|
@ -48,11 +55,16 @@ class CreateRoomDataStore @Inject constructor(
|
||||||
createRoomConfigFlow.tryEmit(createRoomConfigFlow.value.copy(topic = topic?.takeIf { it.isNotEmpty() }))
|
createRoomConfigFlow.tryEmit(createRoomConfigFlow.value.copy(topic = topic?.takeIf { it.isNotEmpty() }))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setAvatarUrl(avatarUrl: String?) {
|
fun setAvatarUri(uri: Uri?, cached: Boolean = false) {
|
||||||
createRoomConfigFlow.tryEmit(createRoomConfigFlow.value.copy(avatarUrl = avatarUrl))
|
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))
|
createRoomConfigFlow.tryEmit(createRoomConfigFlow.value.copy(privacy = privacy))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun clearCachedData() {
|
||||||
|
cachedAvatarUri = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,16 +16,16 @@
|
||||||
|
|
||||||
package io.element.android.features.createroom.impl.configureroom
|
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.CreateRoomConfig
|
||||||
|
import io.element.android.features.createroom.impl.configureroom.avatar.AvatarAction
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
|
||||||
sealed interface ConfigureRoomEvents {
|
sealed interface ConfigureRoomEvents {
|
||||||
data class RoomNameChanged(val name: String) : ConfigureRoomEvents
|
data class RoomNameChanged(val name: String) : ConfigureRoomEvents
|
||||||
data class TopicChanged(val topic: 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 RemoveFromSelection(val matrixUser: MatrixUser) : ConfigureRoomEvents
|
||||||
data class CreateRoom(val config: CreateRoomConfig) : ConfigureRoomEvents
|
data class CreateRoom(val config: CreateRoomConfig) : ConfigureRoomEvents
|
||||||
|
data class HandleAvatarAction(val action: AvatarAction) : ConfigureRoomEvents
|
||||||
object CancelCreateRoom : ConfigureRoomEvents
|
object CancelCreateRoom : ConfigureRoomEvents
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
|
|
||||||
package io.element.android.features.createroom.impl.configureroom
|
package io.element.android.features.createroom.impl.configureroom
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
|
|
@ -26,14 +27,21 @@ import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import io.element.android.features.createroom.impl.CreateRoomConfig
|
import io.element.android.features.createroom.impl.CreateRoomConfig
|
||||||
import io.element.android.features.createroom.impl.CreateRoomDataStore
|
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.Async
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.architecture.execute
|
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.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
|
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.RoomPreset
|
||||||
import io.element.android.libraries.matrix.api.createroom.RoomVisibility
|
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.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -41,6 +49,8 @@ import javax.inject.Inject
|
||||||
class ConfigureRoomPresenter @Inject constructor(
|
class ConfigureRoomPresenter @Inject constructor(
|
||||||
private val dataStore: CreateRoomDataStore,
|
private val dataStore: CreateRoomDataStore,
|
||||||
private val matrixClient: MatrixClient,
|
private val matrixClient: MatrixClient,
|
||||||
|
private val mediaPickerProvider: PickerProvider,
|
||||||
|
private val mediaPreProcessor: MediaPreProcessor,
|
||||||
) : Presenter<ConfigureRoomState> {
|
) : Presenter<ConfigureRoomState> {
|
||||||
|
|
||||||
@Composable
|
@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 localCoroutineScope = rememberCoroutineScope()
|
||||||
val createRoomAction: MutableState<Async<RoomId>> = remember { mutableStateOf(Async.Uninitialized) }
|
val createRoomAction: MutableState<Async<RoomId>> = remember { mutableStateOf(Async.Uninitialized) }
|
||||||
|
|
||||||
|
|
@ -62,12 +89,19 @@ class ConfigureRoomPresenter @Inject constructor(
|
||||||
|
|
||||||
fun handleEvents(event: ConfigureRoomEvents) {
|
fun handleEvents(event: ConfigureRoomEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is ConfigureRoomEvents.AvatarUriChanged -> dataStore.setAvatarUrl(event.uri?.toString())
|
|
||||||
is ConfigureRoomEvents.RoomNameChanged -> dataStore.setRoomName(event.name)
|
is ConfigureRoomEvents.RoomNameChanged -> dataStore.setRoomName(event.name)
|
||||||
is ConfigureRoomEvents.TopicChanged -> dataStore.setTopic(event.topic)
|
is ConfigureRoomEvents.TopicChanged -> dataStore.setTopic(event.topic)
|
||||||
is ConfigureRoomEvents.RoomPrivacyChanged -> dataStore.setPrivacy(event.privacy)
|
is ConfigureRoomEvents.RoomPrivacyChanged -> dataStore.setPrivacy(event.privacy)
|
||||||
is ConfigureRoomEvents.RemoveFromSelection -> dataStore.selectedUserListDataStore.removeUserFromSelection(event.matrixUser)
|
is ConfigureRoomEvents.RemoveFromSelection -> dataStore.selectedUserListDataStore.removeUserFromSelection(event.matrixUser)
|
||||||
is ConfigureRoomEvents.CreateRoom -> createRoom(event.config)
|
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
|
ConfigureRoomEvents.CancelCreateRoom -> createRoomAction.value = Async.Uninitialized
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -75,13 +109,18 @@ class ConfigureRoomPresenter @Inject constructor(
|
||||||
return ConfigureRoomState(
|
return ConfigureRoomState(
|
||||||
config = createRoomConfig.value,
|
config = createRoomConfig.value,
|
||||||
isCreateButtonEnabled = isCreateButtonEnabled,
|
isCreateButtonEnabled = isCreateButtonEnabled,
|
||||||
|
avatarActions = avatarActions,
|
||||||
createRoomAction = createRoomAction.value,
|
createRoomAction = createRoomAction.value,
|
||||||
eventSink = ::handleEvents,
|
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 {
|
suspend {
|
||||||
|
val mxc = config.avatarUri?.let { uploadAvatar(it) }
|
||||||
val params = CreateRoomParameters(
|
val params = CreateRoomParameters(
|
||||||
name = config.roomName,
|
name = config.roomName,
|
||||||
topic = config.topic,
|
topic = config.topic,
|
||||||
|
|
@ -90,9 +129,16 @@ class ConfigureRoomPresenter @Inject constructor(
|
||||||
visibility = if (config.privacy == RoomPrivacy.Public) RoomVisibility.PUBLIC else RoomVisibility.PRIVATE,
|
visibility = if (config.privacy == RoomPrivacy.Public) RoomVisibility.PUBLIC else RoomVisibility.PRIVATE,
|
||||||
preset = if (config.privacy == RoomPrivacy.Public) RoomPreset.PUBLIC_CHAT else RoomPreset.PRIVATE_CHAT,
|
preset = if (config.privacy == RoomPrivacy.Public) RoomPreset.PUBLIC_CHAT else RoomPreset.PRIVATE_CHAT,
|
||||||
invite = config.invites.map { it.userId },
|
invite = config.invites.map { it.userId },
|
||||||
avatar = config.avatarUrl,
|
avatar = mxc,
|
||||||
)
|
)
|
||||||
matrixClient.createRoom(params).getOrThrow()
|
matrixClient.createRoom(params).getOrThrow()
|
||||||
|
.also { dataStore.clearCachedData() }
|
||||||
}.execute(createRoomAction)
|
}.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
|
package io.element.android.features.createroom.impl.configureroom
|
||||||
|
|
||||||
import io.element.android.features.createroom.impl.CreateRoomConfig
|
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.architecture.Async
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
data class ConfigureRoomState(
|
data class ConfigureRoomState(
|
||||||
val config: CreateRoomConfig,
|
val config: CreateRoomConfig,
|
||||||
val isCreateButtonEnabled: Boolean,
|
val isCreateButtonEnabled: Boolean,
|
||||||
|
val avatarActions: ImmutableList<AvatarAction>,
|
||||||
val createRoomAction: Async<RoomId>,
|
val createRoomAction: Async<RoomId>,
|
||||||
val eventSink: (ConfigureRoomEvents) -> Unit
|
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.createroom.impl.CreateRoomConfig
|
||||||
import io.element.android.features.userlist.api.aListOfSelectedUsers
|
import io.element.android.features.userlist.api.aListOfSelectedUsers
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.architecture.Async
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
|
||||||
open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomState> {
|
open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomState> {
|
||||||
override val values: Sequence<ConfigureRoomState>
|
override val values: Sequence<ConfigureRoomState>
|
||||||
|
|
@ -40,6 +41,7 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomSt
|
||||||
fun aConfigureRoomState() = ConfigureRoomState(
|
fun aConfigureRoomState() = ConfigureRoomState(
|
||||||
config = CreateRoomConfig(),
|
config = CreateRoomConfig(),
|
||||||
isCreateButtonEnabled = false,
|
isCreateButtonEnabled = false,
|
||||||
|
avatarActions = persistentListOf(),
|
||||||
createRoomAction = Async.Uninitialized,
|
createRoomAction = Async.Uninitialized,
|
||||||
eventSink = {}
|
eventSink = { },
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@
|
||||||
package io.element.android.features.createroom.impl.configureroom
|
package io.element.android.features.createroom.impl.configureroom
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
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.Spacer
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.selection.selectableGroup
|
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.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
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.R
|
||||||
import io.element.android.features.createroom.impl.components.Avatar
|
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.LabelledTextField
|
||||||
import io.element.android.features.createroom.impl.components.RoomPrivacyOption
|
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.features.userlist.api.components.SelectedUsersList
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.architecture.Async
|
||||||
import io.element.android.libraries.designsystem.components.ProgressDialog
|
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.Text
|
||||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import io.element.android.libraries.ui.strings.R as StringR
|
import io.element.android.libraries.ui.strings.R as StringR
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterialApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ConfigureRoomView(
|
fun ConfigureRoomView(
|
||||||
state: ConfigureRoomState,
|
state: ConfigureRoomState,
|
||||||
|
|
@ -63,13 +67,23 @@ fun ConfigureRoomView(
|
||||||
onBackPressed: () -> Unit = {},
|
onBackPressed: () -> Unit = {},
|
||||||
onRoomCreated: (RoomId) -> Unit = {},
|
onRoomCreated: (RoomId) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
val itemActionsBottomSheetState = rememberModalBottomSheetState(
|
||||||
|
initialValue = ModalBottomSheetValue.Hidden,
|
||||||
|
)
|
||||||
|
|
||||||
if (state.createRoomAction is Async.Success) {
|
if (state.createRoomAction is Async.Success) {
|
||||||
LaunchedEffect(state.createRoomAction) {
|
LaunchedEffect(state.createRoomAction) {
|
||||||
onRoomCreated(state.createRoomAction.state)
|
onRoomCreated(state.createRoomAction.state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val context = LocalContext.current
|
fun onAvatarClicked() {
|
||||||
|
coroutineScope.launch {
|
||||||
|
itemActionsBottomSheetState.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
topBar = {
|
topBar = {
|
||||||
|
|
@ -88,9 +102,9 @@ fun ConfigureRoomView(
|
||||||
) {
|
) {
|
||||||
RoomNameWithAvatar(
|
RoomNameWithAvatar(
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
avatarUri = state.config.avatarUrl?.toUri(),
|
avatarUri = state.config.avatarUri,
|
||||||
roomName = state.config.roomName.orEmpty(),
|
roomName = state.config.roomName.orEmpty(),
|
||||||
onAvatarClick = { Toast.makeText(context, "not implemented yet", Toast.LENGTH_SHORT).show() },
|
onAvatarClick = ::onAvatarClicked,
|
||||||
onRoomNameChanged = { state.eventSink(ConfigureRoomEvents.RoomNameChanged(it)) },
|
onRoomNameChanged = { state.eventSink(ConfigureRoomEvents.RoomNameChanged(it)) },
|
||||||
)
|
)
|
||||||
RoomTopic(
|
RoomTopic(
|
||||||
|
|
@ -112,10 +126,17 @@ fun ConfigureRoomView(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AvatarActionListView(
|
||||||
|
actions = state.avatarActions,
|
||||||
|
modalBottomSheetState = itemActionsBottomSheetState,
|
||||||
|
onActionSelected = { state.eventSink(ConfigureRoomEvents.HandleAvatarAction(it)) }
|
||||||
|
)
|
||||||
|
|
||||||
when (state.createRoomAction) {
|
when (state.createRoomAction) {
|
||||||
is Async.Loading -> {
|
is Async.Loading -> {
|
||||||
ProgressDialog(text = stringResource(StringR.string.common_creating_room))
|
ProgressDialog(text = stringResource(StringR.string.common_creating_room))
|
||||||
}
|
}
|
||||||
|
|
||||||
is Async.Failure -> {
|
is Async.Failure -> {
|
||||||
RetryDialog(
|
RetryDialog(
|
||||||
content = stringResource(R.string.screen_create_room_error_creating_room),
|
content = stringResource(R.string.screen_create_room_error_creating_room),
|
||||||
|
|
@ -123,6 +144,7 @@ fun ConfigureRoomView(
|
||||||
onRetry = { state.eventSink(ConfigureRoomEvents.CreateRoom(state.config)) },
|
onRetry = { state.eventSink(ConfigureRoomEvents.CreateRoom(state.config)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> Unit
|
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 com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.createroom.impl.CreateRoomConfig
|
import io.element.android.features.createroom.impl.CreateRoomConfig
|
||||||
import io.element.android.features.createroom.impl.CreateRoomDataStore
|
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.features.userlist.api.UserListDataStore
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.architecture.Async
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
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.A_THROWABLE
|
||||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
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.persistentListOf
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.After
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.robolectric.RobolectricTestRunner
|
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)
|
@RunWith(RobolectricTestRunner::class)
|
||||||
class ConfigureRoomPresenterTests {
|
class ConfigureRoomPresenterTests {
|
||||||
|
|
||||||
private lateinit var presenter: ConfigureRoomPresenter
|
private lateinit var presenter: ConfigureRoomPresenter
|
||||||
private lateinit var userListDataStore: UserListDataStore
|
private lateinit var userListDataStore: UserListDataStore
|
||||||
|
private lateinit var createRoomDataStore: CreateRoomDataStore
|
||||||
private lateinit var fakeMatrixClient: FakeMatrixClient
|
private lateinit var fakeMatrixClient: FakeMatrixClient
|
||||||
|
private lateinit var fakePickerProvider: FakePickerProvider
|
||||||
|
private lateinit var fakeMediaPreProcessor: FakeMediaPreProcessor
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setup() {
|
fun setup() {
|
||||||
fakeMatrixClient = FakeMatrixClient()
|
fakeMatrixClient = FakeMatrixClient()
|
||||||
userListDataStore = UserListDataStore()
|
userListDataStore = UserListDataStore()
|
||||||
|
createRoomDataStore = CreateRoomDataStore(userListDataStore)
|
||||||
|
fakePickerProvider = FakePickerProvider()
|
||||||
|
fakeMediaPreProcessor = FakeMediaPreProcessor()
|
||||||
presenter = ConfigureRoomPresenter(
|
presenter = ConfigureRoomPresenter(
|
||||||
dataStore = CreateRoomDataStore(userListDataStore),
|
dataStore = createRoomDataStore,
|
||||||
matrixClient = fakeMatrixClient
|
matrixClient = fakeMatrixClient,
|
||||||
|
mediaPickerProvider = fakePickerProvider,
|
||||||
|
mediaPreProcessor = fakeMediaPreProcessor,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
mockkStatic(File::readBytes)
|
||||||
|
every { any<File>().readBytes() } returns byteArrayOf()
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
unmockkAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -67,7 +96,7 @@ class ConfigureRoomPresenterTests {
|
||||||
assertThat(initialState.config.roomName).isNull()
|
assertThat(initialState.config.roomName).isNull()
|
||||||
assertThat(initialState.config.topic).isNull()
|
assertThat(initialState.config.topic).isNull()
|
||||||
assertThat(initialState.config.invites).isEmpty()
|
assertThat(initialState.config.invites).isEmpty()
|
||||||
assertThat(initialState.config.avatarUrl).isNull()
|
assertThat(initialState.config.avatarUri).isNull()
|
||||||
assertThat(initialState.config.privacy).isNull()
|
assertThat(initialState.config.privacy).isNull()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -136,10 +165,28 @@ class ConfigureRoomPresenterTests {
|
||||||
assertThat(newState.config).isEqualTo(expectedConfig)
|
assertThat(newState.config).isEqualTo(expectedConfig)
|
||||||
|
|
||||||
// Room avatar
|
// Room avatar
|
||||||
val anUri = Uri.parse(AN_AVATAR_URL)
|
// Pick avatar
|
||||||
newState.eventSink(ConfigureRoomEvents.AvatarUriChanged(anUri))
|
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()
|
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)
|
assertThat(newState.config).isEqualTo(expectedConfig)
|
||||||
|
|
||||||
// Room privacy
|
// 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
|
@Test
|
||||||
fun `present - trigger retry and cancel actions`() = runTest {
|
fun `present - trigger retry and cancel actions`() = runTest {
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ interface MatrixClient : Closeable {
|
||||||
suspend fun createRoom(createRoomParams: CreateRoomParameters): Result<RoomId>
|
suspend fun createRoom(createRoomParams: CreateRoomParameters): Result<RoomId>
|
||||||
suspend fun createDM(userId: UserId): Result<RoomId>
|
suspend fun createDM(userId: UserId): Result<RoomId>
|
||||||
suspend fun getProfile(userId: UserId): Result<MatrixUser>
|
suspend fun getProfile(userId: UserId): Result<MatrixUser>
|
||||||
|
suspend fun searchUsers(searchTerm: String, limit: Long): Result<MatrixSearchUserResults>
|
||||||
fun startSync()
|
fun startSync()
|
||||||
fun stopSync()
|
fun stopSync()
|
||||||
fun mediaResolver(): MediaResolver
|
fun mediaResolver(): MediaResolver
|
||||||
|
|
@ -58,9 +59,10 @@ interface MatrixClient : Closeable {
|
||||||
height: Long
|
height: Long
|
||||||
): Result<ByteArray>
|
): Result<ByteArray>
|
||||||
|
|
||||||
|
suspend fun uploadMedia(mimeType: String, data: ByteArray): Result<String>
|
||||||
|
|
||||||
fun onSlidingSyncUpdate()
|
fun onSlidingSyncUpdate()
|
||||||
|
|
||||||
fun roomMembershipObserver(): RoomMembershipObserver
|
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 mediaResolver(): MediaResolver = mediaResolver
|
||||||
|
|
||||||
override fun sessionVerificationService(): SessionVerificationService = verificationService
|
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() {
|
override fun onSlidingSyncUpdate() {
|
||||||
if (!verificationService.isReady.value) {
|
if (!verificationService.isReady.value) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -378,13 +392,6 @@ class RustMatrixClient constructor(
|
||||||
|
|
||||||
override fun roomMembershipObserver(): RoomMembershipObserver = roomMembershipObserver
|
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 {
|
private fun File.deleteSessionDirectory(userID: String): Boolean {
|
||||||
// Rust sanitises the user ID replacing invalid characters with an _
|
// Rust sanitises the user ID replacing invalid characters with an _
|
||||||
val sanitisedUserID = userID.replace(":", "_")
|
val sanitisedUserID = userID.replace(":", "_")
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ class FakeMatrixClient(
|
||||||
private val getRoomResults = mutableMapOf<RoomId, MatrixRoom>()
|
private val getRoomResults = mutableMapOf<RoomId, MatrixRoom>()
|
||||||
private val searchUserResults = mutableMapOf<String, Result<MatrixSearchUserResults>>()
|
private val searchUserResults = mutableMapOf<String, Result<MatrixSearchUserResults>>()
|
||||||
private val getProfileResults = mutableMapOf<UserId, Result<MatrixUser>>()
|
private val getProfileResults = mutableMapOf<UserId, Result<MatrixUser>>()
|
||||||
|
private var uploadMediaResult: Result<String> = Result.success(AN_AVATAR_URL)
|
||||||
|
|
||||||
override fun getRoom(roomId: RoomId): MatrixRoom? {
|
override fun getRoom(roomId: RoomId): MatrixRoom? {
|
||||||
return getRoomResults[roomId]
|
return getRoomResults[roomId]
|
||||||
|
|
@ -91,6 +92,10 @@ class FakeMatrixClient(
|
||||||
return getProfileResults[userId] ?: Result.failure(IllegalStateException("No profile found for $userId"))
|
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 startSync() = Unit
|
||||||
|
|
||||||
override fun stopSync() = Unit
|
override fun stopSync() = Unit
|
||||||
|
|
@ -122,6 +127,10 @@ class FakeMatrixClient(
|
||||||
return Result.success(ByteArray(0))
|
return Result.success(ByteArray(0))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun uploadMedia(mimeType: String, data: ByteArray): Result<String> {
|
||||||
|
return uploadMediaResult
|
||||||
|
}
|
||||||
|
|
||||||
override fun sessionVerificationService(): SessionVerificationService = sessionVerificationService
|
override fun sessionVerificationService(): SessionVerificationService = sessionVerificationService
|
||||||
|
|
||||||
override fun pushersService(): PushersService = pushersService
|
override fun pushersService(): PushersService = pushersService
|
||||||
|
|
@ -134,10 +143,6 @@ class FakeMatrixClient(
|
||||||
return RoomMembershipObserver()
|
return RoomMembershipObserver()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun searchUsers(searchTerm: String, limit: Long): Result<MatrixSearchUserResults> {
|
|
||||||
return searchUserResults[searchTerm] ?: Result.failure(IllegalStateException("No response defined for $searchTerm"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mocks
|
// Mocks
|
||||||
|
|
||||||
fun givenLogoutError(failure: Throwable?) {
|
fun givenLogoutError(failure: Throwable?) {
|
||||||
|
|
@ -179,4 +184,8 @@ class FakeMatrixClient(
|
||||||
fun givenGetProfileResult(userId: UserId, result: Result<MatrixUser>) {
|
fun givenGetProfileResult(userId: UserId, result: Result<MatrixUser>) {
|
||||||
getProfileResults[userId] = result
|
getProfileResults[userId] = result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun givenUploadMediaResult(result: Result<String>) {
|
||||||
|
uploadMediaResult = result
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,11 @@ interface PickerProvider {
|
||||||
onResult: (uri: Uri?, mimeType: String?) -> Unit
|
onResult: (uri: Uri?, mimeType: String?) -> Unit
|
||||||
): PickerLauncher<PickVisualMediaRequest, Uri?>
|
): PickerLauncher<PickVisualMediaRequest, Uri?>
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun registerGalleryImagePicker(
|
||||||
|
onResult: (Uri?) -> Unit
|
||||||
|
): PickerLauncher<PickVisualMediaRequest, Uri?>
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun registerFilePicker(
|
fun registerFilePicker(
|
||||||
mimeType: String,
|
mimeType: String,
|
||||||
|
|
@ -38,5 +43,4 @@ interface PickerProvider {
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun registerCameraVideoPicker(onResult: (Uri?) -> Unit): PickerLauncher<Uri, Boolean>
|
fun registerCameraVideoPicker(onResult: (Uri?) -> Unit): PickerLauncher<Uri, Boolean>
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,13 @@ sealed interface PickerType<Input, Output> {
|
||||||
fun getContract(): ActivityResultContract<Input, Output>
|
fun getContract(): ActivityResultContract<Input, Output>
|
||||||
fun getDefaultRequest(): Input
|
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?> {
|
object ImageAndVideo : PickerType<PickVisualMediaRequest, Uri?> {
|
||||||
override fun getContract() = ActivityResultContracts.PickVisualMedia()
|
override fun getContract() = ActivityResultContracts.PickVisualMedia()
|
||||||
override fun getDefaultRequest(): PickVisualMediaRequest {
|
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.
|
* 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.
|
* [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) }
|
return NoOpPickerLauncher { onResult(result, mimeType) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun registerGalleryImagePicker(onResult: (uri: Uri?) -> Unit): PickerLauncher<PickVisualMediaRequest, Uri?> {
|
||||||
|
return NoOpPickerLauncher { onResult(result) }
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun registerFilePicker(mimeType: String, onResult: (Uri?) -> Unit): PickerLauncher<String, Uri?> {
|
override fun registerFilePicker(mimeType: String, onResult: (Uri?) -> Unit): PickerLauncher<String, Uri?> {
|
||||||
return NoOpPickerLauncher { onResult(result) }
|
return NoOpPickerLauncher { onResult(result) }
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:c824fcf4167c6315d0d6568a4d300375a7bf6d4818e3d0dae9ed055f3269313d
|
||||||
|
size 13072
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:8bad887952347b21606a783a19390fe73e5d6deb19390e2d413304398cff810c
|
||||||
|
size 13872
|
||||||
Loading…
Add table
Add a link
Reference in a new issue