Merge pull request #2849 from element-hq/feature/bma/roomNameEdition

Improve room setting edition
This commit is contained in:
Benoit Marty 2024-05-16 09:32:42 +02:00 committed by GitHub
commit 7c96b9f49a
77 changed files with 693 additions and 457 deletions

View file

@ -29,12 +29,10 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
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.rememberCoroutineScope import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
@ -63,9 +61,7 @@ import io.element.android.libraries.matrix.ui.components.SelectedUsersRowList
import io.element.android.libraries.matrix.ui.components.UnsavedAvatar import io.element.android.libraries.matrix.ui.components.UnsavedAvatar
import io.element.android.libraries.permissions.api.PermissionsView import io.element.android.libraries.permissions.api.PermissionsView
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterialApi::class)
@Composable @Composable
fun ConfigureRoomView( fun ConfigureRoomView(
state: ConfigureRoomState, state: ConfigureRoomState,
@ -73,17 +69,12 @@ fun ConfigureRoomView(
onRoomCreated: (RoomId) -> Unit, onRoomCreated: (RoomId) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val coroutineScope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val itemActionsBottomSheetState = rememberModalBottomSheetState( val isAvatarActionsSheetVisible = remember { mutableStateOf(false) }
initialValue = ModalBottomSheetValue.Hidden,
)
fun onAvatarClicked() { fun onAvatarClicked() {
focusManager.clearFocus() focusManager.clearFocus()
coroutineScope.launch { isAvatarActionsSheetVisible.value = true
itemActionsBottomSheetState.show()
}
} }
Scaffold( Scaffold(
@ -143,7 +134,8 @@ fun ConfigureRoomView(
AvatarActionBottomSheet( AvatarActionBottomSheet(
actions = state.avatarActions, actions = state.avatarActions,
modalBottomSheetState = itemActionsBottomSheetState, isVisible = isAvatarActionsSheetVisible.value,
onDismiss = { isAvatarActionsSheetVisible.value = false },
onActionSelected = { state.eventSink(ConfigureRoomEvents.HandleAvatarAction(it)) } onActionSelected = { state.eventSink(ConfigureRoomEvents.HandleAvatarAction(it)) }
) )

View file

@ -24,7 +24,7 @@ import io.element.android.libraries.permissions.api.PermissionsState
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
data class EditUserProfileState( data class EditUserProfileState(
val userId: UserId?, val userId: UserId,
val displayName: String, val displayName: String,
val userAvatarUrl: Uri?, val userAvatarUrl: Uri?,
val avatarActions: ImmutableList<AvatarAction>, val avatarActions: ImmutableList<AvatarAction>,

View file

@ -25,12 +25,10 @@ import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
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.rememberCoroutineScope import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
@ -57,9 +55,8 @@ import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet
import io.element.android.libraries.matrix.ui.components.EditableAvatarView import io.element.android.libraries.matrix.ui.components.EditableAvatarView
import io.element.android.libraries.permissions.api.PermissionsView import io.element.android.libraries.permissions.api.PermissionsView
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun EditUserProfileView( fun EditUserProfileView(
state: EditUserProfileState, state: EditUserProfileState,
@ -67,17 +64,12 @@ fun EditUserProfileView(
onProfileEdited: () -> Unit, onProfileEdited: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val coroutineScope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val itemActionsBottomSheetState = rememberModalBottomSheetState( val isAvatarActionsSheetVisible = remember { mutableStateOf(false) }
initialValue = ModalBottomSheetValue.Hidden,
)
fun onAvatarClicked() { fun onAvatarClicked() {
focusManager.clearFocus() focusManager.clearFocus()
coroutineScope.launch { isAvatarActionsSheetVisible.value = true
itemActionsBottomSheetState.show()
}
} }
Scaffold( Scaffold(
@ -114,7 +106,7 @@ fun EditUserProfileView(
) { ) {
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
EditableAvatarView( EditableAvatarView(
userId = state.userId?.value, matrixId = state.userId.value,
displayName = state.displayName, displayName = state.displayName,
avatarUrl = state.userAvatarUrl, avatarUrl = state.userAvatarUrl,
avatarSize = AvatarSize.RoomHeader, avatarSize = AvatarSize.RoomHeader,
@ -122,14 +114,12 @@ fun EditUserProfileView(
modifier = Modifier.align(Alignment.CenterHorizontally), modifier = Modifier.align(Alignment.CenterHorizontally),
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
state.userId?.let { Text(
Text( modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth(), text = state.userId.value,
text = it.value, style = ElementTheme.typography.fontBodyLgRegular,
style = ElementTheme.typography.fontBodyLgRegular, textAlign = TextAlign.Center,
textAlign = TextAlign.Center, )
)
}
Spacer(modifier = Modifier.height(40.dp)) Spacer(modifier = Modifier.height(40.dp))
LabelledOutlinedTextField( LabelledOutlinedTextField(
label = stringResource(R.string.screen_edit_profile_display_name), label = stringResource(R.string.screen_edit_profile_display_name),
@ -142,7 +132,8 @@ fun EditUserProfileView(
AvatarActionBottomSheet( AvatarActionBottomSheet(
actions = state.avatarActions, actions = state.avatarActions,
modalBottomSheetState = itemActionsBottomSheetState, isVisible = isAvatarActionsSheetVisible.value,
onDismiss = { isAvatarActionsSheetVisible.value = false },
onActionSelected = { state.eventSink(EditUserProfileEvents.HandleAvatarAction(it)) } onActionSelected = { state.eventSink(EditUserProfileEvents.HandleAvatarAction(it)) }
) )

View file

@ -37,6 +37,9 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.StateEventType import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.powerlevels.canSendState import io.element.android.libraries.matrix.api.room.powerlevels.canSendState
import io.element.android.libraries.matrix.ui.media.AvatarAction import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.matrix.ui.room.avatarUrl
import io.element.android.libraries.matrix.ui.room.rawName
import io.element.android.libraries.matrix.ui.room.topic
import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.permissions.api.PermissionsEvents import io.element.android.libraries.permissions.api.PermissionsEvents
@ -61,23 +64,35 @@ class RoomDetailsEditPresenter @Inject constructor(
val cameraPermissionState = cameraPermissionPresenter.present() val cameraPermissionState = cameraPermissionPresenter.present()
val roomSyncUpdateFlow = room.syncUpdateFlow.collectAsState() val roomSyncUpdateFlow = room.syncUpdateFlow.collectAsState()
// Since there is no way to obtain the new avatar uri after uploading a new avatar, val roomAvatarUri = room.avatarUrl()?.toUri()
// just erase the local value when the room field has changed var roomAvatarUriEdited by rememberSaveable { mutableStateOf<Uri?>(null) }
var roomAvatarUri by rememberSaveable(room.avatarUrl) { mutableStateOf(room.avatarUrl?.toUri()) } LaunchedEffect(roomAvatarUri) {
// Every time the roomAvatar change (from sync), we can set the new avatar.
roomAvatarUriEdited = roomAvatarUri
}
var roomName by rememberSaveable { mutableStateOf(room.displayName.trim()) } val roomRawNameTrimmed = room.rawName().orEmpty().trim()
var roomTopic by rememberSaveable { mutableStateOf(room.topic?.trim()) } var roomRawNameEdited by rememberSaveable { mutableStateOf("") }
LaunchedEffect(roomRawNameTrimmed) {
// Every time the rawName change (from sync), we can set the new name.
roomRawNameEdited = roomRawNameTrimmed
}
val roomTopicTrimmed = room.topic().orEmpty().trim()
var roomTopicEdited by rememberSaveable { mutableStateOf("") }
LaunchedEffect(roomTopicTrimmed) {
// Every time the topic change (from sync), we can set the new topic.
roomTopicEdited = roomTopicTrimmed
}
val saveButtonEnabled by remember( val saveButtonEnabled by remember(
roomSyncUpdateFlow.value, roomRawNameTrimmed,
roomName, roomTopicTrimmed,
roomTopic,
roomAvatarUri, roomAvatarUri,
) { ) {
derivedStateOf { derivedStateOf {
roomAvatarUri?.toString()?.trim() != room.avatarUrl?.toUri()?.toString()?.trim() || roomRawNameTrimmed != roomRawNameEdited.trim() ||
roomName.trim() != room.displayName.trim() || roomTopicTrimmed != roomTopicEdited.trim() ||
roomTopic.orEmpty().trim() != room.topic.orEmpty().trim() roomAvatarUri != roomAvatarUriEdited
} }
} }
@ -85,17 +100,17 @@ class RoomDetailsEditPresenter @Inject constructor(
var canChangeTopic by remember { mutableStateOf(false) } var canChangeTopic by remember { mutableStateOf(false) }
var canChangeAvatar by remember { mutableStateOf(false) } var canChangeAvatar by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { LaunchedEffect(roomSyncUpdateFlow.value) {
canChangeName = room.canSendState(StateEventType.ROOM_NAME).getOrElse { false } canChangeName = room.canSendState(StateEventType.ROOM_NAME).getOrElse { false }
canChangeTopic = room.canSendState(StateEventType.ROOM_TOPIC).getOrElse { false } canChangeTopic = room.canSendState(StateEventType.ROOM_TOPIC).getOrElse { false }
canChangeAvatar = room.canSendState(StateEventType.ROOM_AVATAR).getOrElse { false } canChangeAvatar = room.canSendState(StateEventType.ROOM_AVATAR).getOrElse { false }
} }
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker( val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(
onResult = { uri -> if (uri != null) roomAvatarUri = uri } onResult = { uri -> if (uri != null) roomAvatarUriEdited = uri }
) )
val galleryImagePicker = mediaPickerProvider.registerGalleryImagePicker( val galleryImagePicker = mediaPickerProvider.registerGalleryImagePicker(
onResult = { uri -> if (uri != null) roomAvatarUri = uri } onResult = { uri -> if (uri != null) roomAvatarUriEdited = uri }
) )
LaunchedEffect(cameraPermissionState.permissionGranted) { LaunchedEffect(cameraPermissionState.permissionGranted) {
@ -105,12 +120,12 @@ class RoomDetailsEditPresenter @Inject constructor(
} }
} }
val avatarActions by remember(roomAvatarUri) { val avatarActions by remember(roomAvatarUriEdited) {
derivedStateOf { derivedStateOf {
listOfNotNull( listOfNotNull(
AvatarAction.TakePhoto, AvatarAction.TakePhoto,
AvatarAction.ChoosePhoto, AvatarAction.ChoosePhoto,
AvatarAction.Remove.takeIf { roomAvatarUri != null }, AvatarAction.Remove.takeIf { roomAvatarUriEdited != null },
).toImmutableList() ).toImmutableList()
} }
} }
@ -119,7 +134,15 @@ class RoomDetailsEditPresenter @Inject constructor(
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
fun handleEvents(event: RoomDetailsEditEvents) { fun handleEvents(event: RoomDetailsEditEvents) {
when (event) { when (event) {
is RoomDetailsEditEvents.Save -> localCoroutineScope.saveChanges(roomName, roomTopic, roomAvatarUri, saveAction) is RoomDetailsEditEvents.Save -> localCoroutineScope.saveChanges(
currentNameTrimmed = roomRawNameTrimmed,
newNameTrimmed = roomRawNameEdited.trim(),
currentTopicTrimmed = roomTopicTrimmed,
newTopicTrimmed = roomTopicEdited.trim(),
currentAvatar = roomAvatarUri,
newAvatarUri = roomAvatarUriEdited,
action = saveAction,
)
is RoomDetailsEditEvents.HandleAvatarAction -> { is RoomDetailsEditEvents.HandleAvatarAction -> {
when (event.action) { when (event.action) {
AvatarAction.ChoosePhoto -> galleryImagePicker.launch() AvatarAction.ChoosePhoto -> galleryImagePicker.launch()
@ -129,23 +152,23 @@ class RoomDetailsEditPresenter @Inject constructor(
pendingPermissionRequest = true pendingPermissionRequest = true
cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions) cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions)
} }
AvatarAction.Remove -> roomAvatarUri = null AvatarAction.Remove -> roomAvatarUriEdited = null
} }
} }
is RoomDetailsEditEvents.UpdateRoomName -> roomName = event.name is RoomDetailsEditEvents.UpdateRoomName -> roomRawNameEdited = event.name
is RoomDetailsEditEvents.UpdateRoomTopic -> roomTopic = event.topic.takeUnless { it.isEmpty() } is RoomDetailsEditEvents.UpdateRoomTopic -> roomTopicEdited = event.topic
RoomDetailsEditEvents.CancelSaveChanges -> saveAction.value = AsyncAction.Uninitialized RoomDetailsEditEvents.CancelSaveChanges -> saveAction.value = AsyncAction.Uninitialized
} }
} }
return RoomDetailsEditState( return RoomDetailsEditState(
roomId = room.roomId.value, roomId = room.roomId,
roomName = roomName, roomRawName = roomRawNameEdited,
canChangeName = canChangeName, canChangeName = canChangeName,
roomTopic = roomTopic.orEmpty(), roomTopic = roomTopicEdited,
canChangeTopic = canChangeTopic, canChangeTopic = canChangeTopic,
roomAvatarUrl = roomAvatarUri, roomAvatarUrl = roomAvatarUriEdited,
canChangeAvatar = canChangeAvatar, canChangeAvatar = canChangeAvatar,
avatarActions = avatarActions, avatarActions = avatarActions,
saveButtonEnabled = saveButtonEnabled, saveButtonEnabled = saveButtonEnabled,
@ -156,25 +179,28 @@ class RoomDetailsEditPresenter @Inject constructor(
} }
private fun CoroutineScope.saveChanges( private fun CoroutineScope.saveChanges(
name: String, currentNameTrimmed: String,
topic: String?, newNameTrimmed: String,
avatarUri: Uri?, currentTopicTrimmed: String,
newTopicTrimmed: String,
currentAvatar: Uri?,
newAvatarUri: Uri?,
action: MutableState<AsyncAction<Unit>>, action: MutableState<AsyncAction<Unit>>,
) = launch { ) = launch {
val results = mutableListOf<Result<Unit>>() val results = mutableListOf<Result<Unit>>()
suspend { suspend {
if (topic.orEmpty().trim() != room.topic.orEmpty().trim()) { if (newTopicTrimmed != currentTopicTrimmed) {
results.add(room.setTopic(topic.orEmpty()).onFailure { results.add(room.setTopic(newTopicTrimmed).onFailure {
Timber.e(it, "Failed to set room topic") Timber.e(it, "Failed to set room topic")
}) })
} }
if (name.isNotEmpty() && name.trim() != room.displayName.trim()) { if (newNameTrimmed.isNotEmpty() && newNameTrimmed != currentNameTrimmed) {
results.add(room.setName(name).onFailure { results.add(room.setName(newNameTrimmed).onFailure {
Timber.e(it, "Failed to set room name") Timber.e(it, "Failed to set room name")
}) })
} }
if (avatarUri?.toString()?.trim() != room.avatarUrl?.trim()) { if (newAvatarUri != currentAvatar) {
results.add(updateAvatar(avatarUri).onFailure { results.add(updateAvatar(newAvatarUri).onFailure {
Timber.e(it, "Failed to update avatar") Timber.e(it, "Failed to update avatar")
}) })
} }

View file

@ -18,13 +18,15 @@ package io.element.android.features.roomdetails.impl.edit
import android.net.Uri import android.net.Uri
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.media.AvatarAction import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.permissions.api.PermissionsState import io.element.android.libraries.permissions.api.PermissionsState
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
data class RoomDetailsEditState( data class RoomDetailsEditState(
val roomId: String, val roomId: RoomId,
val roomName: String, /** The raw room name (i.e. the room name from the state event `m.room.name`), not the display name. */
val roomRawName: String,
val canChangeName: Boolean, val canChangeName: Boolean,
val roomTopic: String, val roomTopic: String,
val canChangeTopic: Boolean, val canChangeTopic: Boolean,

View file

@ -19,33 +19,50 @@ package io.element.android.features.roomdetails.impl.edit
import android.net.Uri import android.net.Uri
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.permissions.api.PermissionsState
import io.element.android.libraries.permissions.api.aPermissionsState import io.element.android.libraries.permissions.api.aPermissionsState
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList
open class RoomDetailsEditStateProvider : PreviewParameterProvider<RoomDetailsEditState> { open class RoomDetailsEditStateProvider : PreviewParameterProvider<RoomDetailsEditState> {
override val values: Sequence<RoomDetailsEditState> override val values: Sequence<RoomDetailsEditState>
get() = sequenceOf( get() = sequenceOf(
aRoomDetailsEditState(), aRoomDetailsEditState(),
aRoomDetailsEditState().copy(roomTopic = ""), aRoomDetailsEditState(roomTopic = ""),
aRoomDetailsEditState().copy(roomAvatarUrl = Uri.parse("example://uri")), aRoomDetailsEditState(roomRawName = ""),
aRoomDetailsEditState().copy(canChangeName = true, canChangeTopic = false, canChangeAvatar = true, saveButtonEnabled = false), aRoomDetailsEditState(roomAvatarUrl = Uri.parse("example://uri")),
aRoomDetailsEditState().copy(canChangeName = false, canChangeTopic = true, canChangeAvatar = false, saveButtonEnabled = false), aRoomDetailsEditState(canChangeName = true, canChangeTopic = false, canChangeAvatar = true, saveButtonEnabled = false),
aRoomDetailsEditState().copy(saveAction = AsyncAction.Loading), aRoomDetailsEditState(canChangeName = false, canChangeTopic = true, canChangeAvatar = false, saveButtonEnabled = false),
aRoomDetailsEditState().copy(saveAction = AsyncAction.Failure(Throwable("Whelp"))) aRoomDetailsEditState(saveAction = AsyncAction.Loading),
aRoomDetailsEditState(saveAction = AsyncAction.Failure(Throwable("Whelp"))),
) )
} }
fun aRoomDetailsEditState() = RoomDetailsEditState( fun aRoomDetailsEditState(
roomId = "a room id", roomId: RoomId = RoomId("!aRoomId:aDomain"),
roomName = "Marketing", roomRawName: String = "Marketing",
canChangeName = true, canChangeName: Boolean = true,
roomTopic = "a room topic that is quite long so should wrap onto multiple lines", roomTopic: String = "a room topic that is quite long so should wrap onto multiple lines",
canChangeTopic = true, canChangeTopic: Boolean = true,
roomAvatarUrl = null, roomAvatarUrl: Uri? = null,
canChangeAvatar = true, canChangeAvatar: Boolean = true,
avatarActions = persistentListOf(), avatarActions: List<AvatarAction> = emptyList(),
saveButtonEnabled = true, saveButtonEnabled: Boolean = true,
saveAction = AsyncAction.Uninitialized, saveAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
cameraPermissionState = aPermissionsState(showDialog = false), cameraPermissionState: PermissionsState = aPermissionsState(showDialog = false),
eventSink = {} eventSink: (RoomDetailsEditEvents) -> Unit = {},
) = RoomDetailsEditState(
roomId = roomId,
roomRawName = roomRawName,
canChangeName = canChangeName,
roomTopic = roomTopic,
canChangeTopic = canChangeTopic,
roomAvatarUrl = roomAvatarUrl,
canChangeAvatar = canChangeAvatar,
avatarActions = avatarActions.toImmutableList(),
saveButtonEnabled = saveButtonEnabled,
saveAction = saveAction,
cameraPermissionState = cameraPermissionState,
eventSink = eventSink,
) )

View file

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class) @file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.features.roomdetails.impl.edit package io.element.android.features.roomdetails.impl.edit
@ -29,13 +29,11 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
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.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -61,9 +59,7 @@ import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet
import io.element.android.libraries.matrix.ui.components.EditableAvatarView import io.element.android.libraries.matrix.ui.components.EditableAvatarView
import io.element.android.libraries.permissions.api.PermissionsView import io.element.android.libraries.permissions.api.PermissionsView
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
fun RoomDetailsEditView( fun RoomDetailsEditView(
state: RoomDetailsEditState, state: RoomDetailsEditState,
@ -71,17 +67,12 @@ fun RoomDetailsEditView(
onRoomEdited: () -> Unit, onRoomEdited: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val coroutineScope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val itemActionsBottomSheetState = rememberModalBottomSheetState( val isAvatarActionsSheetVisible = remember { mutableStateOf(false) }
initialValue = ModalBottomSheetValue.Hidden,
)
fun onAvatarClicked() { fun onAvatarClicked() {
focusManager.clearFocus() focusManager.clearFocus()
coroutineScope.launch { isAvatarActionsSheetVisible.value = true
itemActionsBottomSheetState.show()
}
} }
Scaffold( Scaffold(
@ -118,8 +109,9 @@ fun RoomDetailsEditView(
) { ) {
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
EditableAvatarView( EditableAvatarView(
userId = state.roomId, matrixId = state.roomId.value,
displayName = state.roomName, // As per Element Web, we use the raw name for the avatar as well
displayName = state.roomRawName,
avatarUrl = state.roomAvatarUrl, avatarUrl = state.roomAvatarUrl,
avatarSize = AvatarSize.EditRoomDetails, avatarSize = AvatarSize.EditRoomDetails,
onAvatarClicked = ::onAvatarClicked, onAvatarClicked = ::onAvatarClicked,
@ -130,7 +122,7 @@ fun RoomDetailsEditView(
if (state.canChangeName) { if (state.canChangeName) {
LabelledTextField( LabelledTextField(
label = stringResource(id = R.string.screen_room_details_room_name_label), label = stringResource(id = R.string.screen_room_details_room_name_label),
value = state.roomName, value = state.roomRawName,
placeholder = stringResource(CommonStrings.common_room_name_placeholder), placeholder = stringResource(CommonStrings.common_room_name_placeholder),
singleLine = true, singleLine = true,
onValueChange = { state.eventSink(RoomDetailsEditEvents.UpdateRoomName(it)) }, onValueChange = { state.eventSink(RoomDetailsEditEvents.UpdateRoomName(it)) },
@ -138,7 +130,7 @@ fun RoomDetailsEditView(
} else { } else {
LabelledReadOnlyField( LabelledReadOnlyField(
title = stringResource(R.string.screen_room_details_room_name_label), title = stringResource(R.string.screen_room_details_room_name_label),
value = state.roomName value = state.roomRawName
) )
} }
@ -166,7 +158,8 @@ fun RoomDetailsEditView(
AvatarActionBottomSheet( AvatarActionBottomSheet(
actions = state.avatarActions, actions = state.avatarActions,
modalBottomSheetState = itemActionsBottomSheetState, isVisible = isAvatarActionsSheetVisible.value,
onDismiss = { isAvatarActionsSheetVisible.value = false },
onActionSelected = { state.eventSink(RoomDetailsEditEvents.HandleAvatarAction(it)) } onActionSelected = { state.eventSink(RoomDetailsEditEvents.HandleAvatarAction(it)) }
) )

View file

@ -0,0 +1,59 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomdetails
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
fun aMatrixRoom(
roomId: RoomId = A_ROOM_ID,
displayName: String = A_ROOM_NAME,
rawName: String? = displayName,
topic: String? = "A topic",
avatarUrl: String? = "https://matrix.org/avatar.jpg",
isEncrypted: Boolean = true,
isPublic: Boolean = true,
isDirect: Boolean = false,
notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(),
emitRoomInfo: Boolean = false,
) = FakeMatrixRoom(
roomId = roomId,
displayName = displayName,
topic = topic,
avatarUrl = avatarUrl,
isEncrypted = isEncrypted,
isPublic = isPublic,
isDirect = isDirect,
notificationSettingsService = notificationSettingsService
).apply {
if (emitRoomInfo) {
givenRoomInfo(
aRoomInfo(
name = displayName,
rawName = rawName,
topic = topic,
avatarUrl = avatarUrl,
isDirect = isDirect,
isPublic = isPublic,
)
)
}
}

View file

@ -37,14 +37,11 @@ import io.element.android.features.roomdetails.impl.members.details.RoomMemberDe
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.StateEventType import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2 import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
@ -473,23 +470,3 @@ class RoomDetailsPresenterTests {
} }
} }
} }
fun aMatrixRoom(
roomId: RoomId = A_ROOM_ID,
displayName: String = A_ROOM_NAME,
topic: String? = "A topic",
avatarUrl: String? = "https://matrix.org/avatar.jpg",
isEncrypted: Boolean = true,
isPublic: Boolean = true,
isDirect: Boolean = false,
notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService()
) = FakeMatrixRoom(
roomId = roomId,
displayName = displayName,
topic = topic,
avatarUrl = avatarUrl,
isEncrypted = isEncrypted,
isPublic = isPublic,
isDirect = isDirect,
notificationSettingsService = notificationSettingsService
)

View file

@ -19,6 +19,7 @@ package io.element.android.features.roomdetails.edit
import android.net.Uri import android.net.Uri
import app.cash.molecule.RecompositionMode import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.ReceiveTurbine
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.roomdetails.aMatrixRoom import io.element.android.features.roomdetails.aMatrixRoom
@ -28,6 +29,8 @@ import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.StateEventType import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_ROOM_RAW_NAME
import io.element.android.libraries.matrix.ui.media.AvatarAction import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.mediapickers.test.FakePickerProvider import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaupload.api.MediaUploadInfo import io.element.android.libraries.mediaupload.api.MediaUploadInfo
@ -90,15 +93,19 @@ class RoomDetailsEditPresenterTest {
@Test @Test
fun `present - initial state is created from room info`() = runTest { fun `present - initial state is created from room info`() = runTest {
val room = aMatrixRoom(avatarUrl = AN_AVATAR_URL) val room = aMatrixRoom(
avatarUrl = AN_AVATAR_URL,
displayName = A_ROOM_NAME,
rawName = A_ROOM_RAW_NAME,
emitRoomInfo = true,
)
val presenter = createRoomDetailsEditPresenter(room) val presenter = createRoomDetailsEditPresenter(room)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitFirstItem()
assertThat(initialState.roomId).isEqualTo(room.roomId.value) assertThat(initialState.roomId).isEqualTo(room.roomId)
assertThat(initialState.roomName).isEqualTo(room.displayName) assertThat(initialState.roomRawName).isEqualTo(A_ROOM_RAW_NAME)
assertThat(initialState.roomAvatarUrl).isEqualTo(roomAvatarUri) assertThat(initialState.roomAvatarUrl).isEqualTo(roomAvatarUri)
assertThat(initialState.roomTopic).isEqualTo(room.topic.orEmpty()) assertThat(initialState.roomTopic).isEqualTo(room.topic.orEmpty())
assertThat(initialState.avatarActions).containsExactly( assertThat(initialState.avatarActions).containsExactly(
@ -119,7 +126,6 @@ class RoomDetailsEditPresenterTest {
givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.failure(Throwable("Oops"))) givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.failure(Throwable("Oops")))
} }
val presenter = createRoomDetailsEditPresenter(room) val presenter = createRoomDetailsEditPresenter(room)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -128,7 +134,6 @@ class RoomDetailsEditPresenterTest {
assertThat(initialState.canChangeName).isFalse() assertThat(initialState.canChangeName).isFalse()
assertThat(initialState.canChangeAvatar).isFalse() assertThat(initialState.canChangeAvatar).isFalse()
assertThat(initialState.canChangeTopic).isFalse() assertThat(initialState.canChangeTopic).isFalse()
// When the asynchronous check completes, the single field we can edit is true // When the asynchronous check completes, the single field we can edit is true
val settledState = awaitItem() val settledState = awaitItem()
assertThat(settledState.canChangeName).isTrue() assertThat(settledState.canChangeName).isTrue()
@ -145,7 +150,6 @@ class RoomDetailsEditPresenterTest {
givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.failure(Throwable("Oops"))) givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.failure(Throwable("Oops")))
} }
val presenter = createRoomDetailsEditPresenter(room) val presenter = createRoomDetailsEditPresenter(room)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -154,7 +158,6 @@ class RoomDetailsEditPresenterTest {
assertThat(initialState.canChangeName).isFalse() assertThat(initialState.canChangeName).isFalse()
assertThat(initialState.canChangeAvatar).isFalse() assertThat(initialState.canChangeAvatar).isFalse()
assertThat(initialState.canChangeTopic).isFalse() assertThat(initialState.canChangeTopic).isFalse()
// When the asynchronous check completes, the single field we can edit is true // When the asynchronous check completes, the single field we can edit is true
val settledState = awaitItem() val settledState = awaitItem()
assertThat(settledState.canChangeName).isFalse() assertThat(settledState.canChangeName).isFalse()
@ -171,7 +174,6 @@ class RoomDetailsEditPresenterTest {
givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.success(true)) givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.success(true))
} }
val presenter = createRoomDetailsEditPresenter(room) val presenter = createRoomDetailsEditPresenter(room)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -180,7 +182,6 @@ class RoomDetailsEditPresenterTest {
assertThat(initialState.canChangeName).isFalse() assertThat(initialState.canChangeName).isFalse()
assertThat(initialState.canChangeAvatar).isFalse() assertThat(initialState.canChangeAvatar).isFalse()
assertThat(initialState.canChangeTopic).isFalse() assertThat(initialState.canChangeTopic).isFalse()
// When the asynchronous check completes, the single field we can edit is true // When the asynchronous check completes, the single field we can edit is true
val settledState = awaitItem() val settledState = awaitItem()
assertThat(settledState.canChangeName).isFalse() assertThat(settledState.canChangeName).isFalse()
@ -191,42 +192,42 @@ class RoomDetailsEditPresenterTest {
@Test @Test
fun `present - updates state in response to changes`() = runTest { fun `present - updates state in response to changes`() = runTest {
val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL) val room = aMatrixRoom(
topic = "My topic",
displayName = "Name",
avatarUrl = AN_AVATAR_URL,
emitRoomInfo = true,
)
val presenter = createRoomDetailsEditPresenter(room) val presenter = createRoomDetailsEditPresenter(room)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitFirstItem()
assertThat(initialState.roomTopic).isEqualTo("My topic") assertThat(initialState.roomTopic).isEqualTo("My topic")
assertThat(initialState.roomName).isEqualTo("Name") assertThat(initialState.roomRawName).isEqualTo("Name")
assertThat(initialState.roomAvatarUrl).isEqualTo(roomAvatarUri) assertThat(initialState.roomAvatarUrl).isEqualTo(roomAvatarUri)
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name II")) initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name II"))
awaitItem().apply { awaitItem().apply {
assertThat(roomTopic).isEqualTo("My topic") assertThat(roomTopic).isEqualTo("My topic")
assertThat(roomName).isEqualTo("Name II") assertThat(roomRawName).isEqualTo("Name II")
assertThat(roomAvatarUrl).isEqualTo(roomAvatarUri) assertThat(roomAvatarUrl).isEqualTo(roomAvatarUri)
} }
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name III")) initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name III"))
awaitItem().apply { awaitItem().apply {
assertThat(roomTopic).isEqualTo("My topic") assertThat(roomTopic).isEqualTo("My topic")
assertThat(roomName).isEqualTo("Name III") assertThat(roomRawName).isEqualTo("Name III")
assertThat(roomAvatarUrl).isEqualTo(roomAvatarUri) assertThat(roomAvatarUrl).isEqualTo(roomAvatarUri)
} }
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("Another topic")) initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("Another topic"))
awaitItem().apply { awaitItem().apply {
assertThat(roomTopic).isEqualTo("Another topic") assertThat(roomTopic).isEqualTo("Another topic")
assertThat(roomName).isEqualTo("Name III") assertThat(roomRawName).isEqualTo("Name III")
assertThat(roomAvatarUrl).isEqualTo(roomAvatarUri) assertThat(roomAvatarUrl).isEqualTo(roomAvatarUri)
} }
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove)) initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove))
awaitItem().apply { awaitItem().apply {
assertThat(roomTopic).isEqualTo("Another topic") assertThat(roomTopic).isEqualTo("Another topic")
assertThat(roomName).isEqualTo("Name III") assertThat(roomRawName).isEqualTo("Name III")
assertThat(roomAvatarUrl).isNull() assertThat(roomAvatarUrl).isNull()
} }
} }
@ -234,18 +235,19 @@ class RoomDetailsEditPresenterTest {
@Test @Test
fun `present - obtains avatar uris from gallery`() = runTest { fun `present - obtains avatar uris from gallery`() = runTest {
val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL) val room = aMatrixRoom(
topic = "My topic",
displayName = "Name",
avatarUrl = AN_AVATAR_URL,
emitRoomInfo = true,
)
fakePickerProvider.givenResult(anotherAvatarUri) fakePickerProvider.givenResult(anotherAvatarUri)
val presenter = createRoomDetailsEditPresenter(room) val presenter = createRoomDetailsEditPresenter(room)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitFirstItem()
assertThat(initialState.roomAvatarUrl).isEqualTo(roomAvatarUri) assertThat(initialState.roomAvatarUrl).isEqualTo(roomAvatarUri)
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
awaitItem().apply { awaitItem().apply {
assertThat(roomAvatarUrl).isEqualTo(anotherAvatarUri) assertThat(roomAvatarUrl).isEqualTo(anotherAvatarUri)
@ -255,19 +257,22 @@ class RoomDetailsEditPresenterTest {
@Test @Test
fun `present - obtains avatar uris from camera`() = runTest { fun `present - obtains avatar uris from camera`() = runTest {
val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL) val room = aMatrixRoom(
topic = "My topic",
displayName = "Name",
avatarUrl = AN_AVATAR_URL,
emitRoomInfo = true,
)
fakePickerProvider.givenResult(anotherAvatarUri) fakePickerProvider.givenResult(anotherAvatarUri)
val fakePermissionsPresenter = FakePermissionsPresenter() val fakePermissionsPresenter = FakePermissionsPresenter()
val presenter = createRoomDetailsEditPresenter( val presenter = createRoomDetailsEditPresenter(
room = room, room = room,
permissionsPresenter = fakePermissionsPresenter, permissionsPresenter = fakePermissionsPresenter,
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitFirstItem()
assertThat(initialState.roomAvatarUrl).isEqualTo(roomAvatarUri) assertThat(initialState.roomAvatarUrl).isEqualTo(roomAvatarUri)
assertThat(initialState.cameraPermissionState.permissionGranted).isFalse() assertThat(initialState.cameraPermissionState.permissionGranted).isFalse()
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.TakePhoto)) initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.TakePhoto))
@ -288,48 +293,44 @@ class RoomDetailsEditPresenterTest {
@Test @Test
fun `present - updates save button state`() = runTest { fun `present - updates save button state`() = runTest {
val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL) val room = aMatrixRoom(
topic = "My topic",
displayName = "Name",
avatarUrl = AN_AVATAR_URL,
emitRoomInfo = true,
)
fakePickerProvider.givenResult(roomAvatarUri) fakePickerProvider.givenResult(roomAvatarUri)
val presenter = createRoomDetailsEditPresenter(room) val presenter = createRoomDetailsEditPresenter(room)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitFirstItem()
assertThat(initialState.saveButtonEnabled).isFalse() assertThat(initialState.saveButtonEnabled).isFalse()
// Once a change is made, the save button is enabled // Once a change is made, the save button is enabled
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name II")) initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name II"))
awaitItem().apply { awaitItem().apply {
assertThat(saveButtonEnabled).isTrue() assertThat(saveButtonEnabled).isTrue()
} }
// If it's reverted then the save disables again // If it's reverted then the save disables again
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name")) initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name"))
awaitItem().apply { awaitItem().apply {
assertThat(saveButtonEnabled).isFalse() assertThat(saveButtonEnabled).isFalse()
} }
// Make a change... // Make a change...
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("Another topic")) initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("Another topic"))
awaitItem().apply { awaitItem().apply {
assertThat(saveButtonEnabled).isTrue() assertThat(saveButtonEnabled).isTrue()
} }
// Revert it... // Revert it...
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("My topic")) initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("My topic"))
awaitItem().apply { awaitItem().apply {
assertThat(saveButtonEnabled).isFalse() assertThat(saveButtonEnabled).isFalse()
} }
// Make a change... // Make a change...
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove)) initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove))
awaitItem().apply { awaitItem().apply {
assertThat(saveButtonEnabled).isTrue() assertThat(saveButtonEnabled).isTrue()
} }
// Revert it... // Revert it...
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
awaitItem().apply { awaitItem().apply {
@ -340,48 +341,44 @@ class RoomDetailsEditPresenterTest {
@Test @Test
fun `present - updates save button state when initial values are null`() = runTest { fun `present - updates save button state when initial values are null`() = runTest {
val room = aMatrixRoom(topic = null, displayName = "fallback", avatarUrl = null) val room = aMatrixRoom(
topic = null,
displayName = "fallback",
avatarUrl = null,
emitRoomInfo = true,
)
fakePickerProvider.givenResult(roomAvatarUri) fakePickerProvider.givenResult(roomAvatarUri)
val presenter = createRoomDetailsEditPresenter(room) val presenter = createRoomDetailsEditPresenter(room)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitFirstItem()
assertThat(initialState.saveButtonEnabled).isFalse() assertThat(initialState.saveButtonEnabled).isFalse()
// Once a change is made, the save button is enabled // Once a change is made, the save button is enabled
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name II")) initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name II"))
awaitItem().apply { awaitItem().apply {
assertThat(saveButtonEnabled).isTrue() assertThat(saveButtonEnabled).isTrue()
} }
// If it's reverted then the save disables again // If it's reverted then the save disables again
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("fallback")) initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("fallback"))
awaitItem().apply { awaitItem().apply {
assertThat(saveButtonEnabled).isFalse() assertThat(saveButtonEnabled).isFalse()
} }
// Make a change... // Make a change...
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("Another topic")) initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("Another topic"))
awaitItem().apply { awaitItem().apply {
assertThat(saveButtonEnabled).isTrue() assertThat(saveButtonEnabled).isTrue()
} }
// Revert it... // Revert it...
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("")) initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic(""))
awaitItem().apply { awaitItem().apply {
assertThat(saveButtonEnabled).isFalse() assertThat(saveButtonEnabled).isFalse()
} }
// Make a change... // Make a change...
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
awaitItem().apply { awaitItem().apply {
assertThat(saveButtonEnabled).isTrue() assertThat(saveButtonEnabled).isTrue()
} }
// Revert it... // Revert it...
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove)) initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove))
awaitItem().apply { awaitItem().apply {
@ -392,15 +389,17 @@ class RoomDetailsEditPresenterTest {
@Test @Test
fun `present - save changes room details if different`() = runTest { fun `present - save changes room details if different`() = runTest {
val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL) val room = aMatrixRoom(
topic = "My topic",
displayName = "Name",
avatarUrl = AN_AVATAR_URL,
emitRoomInfo = true,
)
val presenter = createRoomDetailsEditPresenter(room) val presenter = createRoomDetailsEditPresenter(room)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitFirstItem()
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("New name")) initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("New name"))
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("New topic")) initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("New topic"))
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove)) initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove))
@ -410,31 +409,24 @@ class RoomDetailsEditPresenterTest {
assertThat(room.newTopic).isEqualTo("New topic") assertThat(room.newTopic).isEqualTo("New topic")
assertThat(room.newAvatarData).isNull() assertThat(room.newAvatarData).isNull()
assertThat(room.removedAvatar).isTrue() assertThat(room.removedAvatar).isTrue()
cancelAndIgnoreRemainingEvents()
} }
} }
@Test @Test
fun `present - save doesn't change room details if they're the same trimmed`() = runTest { fun `present - save doesn't change room details if they're the same trimmed`() = runTest {
val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL) val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL)
val presenter = createRoomDetailsEditPresenter(room) val presenter = createRoomDetailsEditPresenter(room)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName(" Name ")) initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName(" Name "))
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic(" My topic ")) initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic(" My topic "))
initialState.eventSink(RoomDetailsEditEvents.Save) initialState.eventSink(RoomDetailsEditEvents.Save)
assertThat(room.newName).isNull() assertThat(room.newName).isNull()
assertThat(room.newTopic).isNull() assertThat(room.newTopic).isNull()
assertThat(room.newAvatarData).isNull() assertThat(room.newAvatarData).isNull()
assertThat(room.removedAvatar).isFalse() assertThat(room.removedAvatar).isFalse()
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
} }
@ -442,22 +434,17 @@ class RoomDetailsEditPresenterTest {
@Test @Test
fun `present - save doesn't change topic if it was unset and is now blank`() = runTest { fun `present - save doesn't change topic if it was unset and is now blank`() = runTest {
val room = aMatrixRoom(topic = null, displayName = "Name", avatarUrl = AN_AVATAR_URL) val room = aMatrixRoom(topic = null, displayName = "Name", avatarUrl = AN_AVATAR_URL)
val presenter = createRoomDetailsEditPresenter(room) val presenter = createRoomDetailsEditPresenter(room)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("")) initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic(""))
initialState.eventSink(RoomDetailsEditEvents.Save) initialState.eventSink(RoomDetailsEditEvents.Save)
assertThat(room.newName).isNull() assertThat(room.newName).isNull()
assertThat(room.newTopic).isNull() assertThat(room.newTopic).isNull()
assertThat(room.newAvatarData).isNull() assertThat(room.newAvatarData).isNull()
assertThat(room.removedAvatar).isFalse() assertThat(room.removedAvatar).isFalse()
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
} }
@ -465,22 +452,17 @@ class RoomDetailsEditPresenterTest {
@Test @Test
fun `present - save doesn't change name if it's now empty`() = runTest { fun `present - save doesn't change name if it's now empty`() = runTest {
val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL) val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL)
val presenter = createRoomDetailsEditPresenter(room) val presenter = createRoomDetailsEditPresenter(room)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("")) initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName(""))
initialState.eventSink(RoomDetailsEditEvents.Save) initialState.eventSink(RoomDetailsEditEvents.Save)
assertThat(room.newName).isNull() assertThat(room.newName).isNull()
assertThat(room.newTopic).isNull() assertThat(room.newTopic).isNull()
assertThat(room.newAvatarData).isNull() assertThat(room.newAvatarData).isNull()
assertThat(room.removedAvatar).isFalse() assertThat(room.removedAvatar).isFalse()
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
} }
@ -488,20 +470,15 @@ class RoomDetailsEditPresenterTest {
@Test @Test
fun `present - save processes and sets avatar when processor returns successfully`() = runTest { fun `present - save processes and sets avatar when processor returns successfully`() = runTest {
val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL) val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL)
givenPickerReturnsFile() givenPickerReturnsFile()
val presenter = createRoomDetailsEditPresenter(room) val presenter = createRoomDetailsEditPresenter(room)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
initialState.eventSink(RoomDetailsEditEvents.Save) initialState.eventSink(RoomDetailsEditEvents.Save)
skipItems(3) skipItems(3)
assertThat(room.newName).isNull() assertThat(room.newName).isNull()
assertThat(room.newTopic).isNull() assertThat(room.newTopic).isNull()
assertThat(room.newAvatarData).isSameInstanceAs(fakeFileContents) assertThat(room.newAvatarData).isSameInstanceAs(fakeFileContents)
@ -512,89 +489,92 @@ class RoomDetailsEditPresenterTest {
@Test @Test
fun `present - save does not set avatar data if processor fails`() = runTest { fun `present - save does not set avatar data if processor fails`() = runTest {
val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL) val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL)
fakePickerProvider.givenResult(anotherAvatarUri) fakePickerProvider.givenResult(anotherAvatarUri)
fakeMediaPreProcessor.givenResult(Result.failure(Throwable("Oh no"))) fakeMediaPreProcessor.givenResult(Result.failure(Throwable("Oh no")))
val presenter = createRoomDetailsEditPresenter(room) val presenter = createRoomDetailsEditPresenter(room)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
initialState.eventSink(RoomDetailsEditEvents.Save) initialState.eventSink(RoomDetailsEditEvents.Save)
skipItems(2) skipItems(2)
assertThat(room.newName).isNull() assertThat(room.newName).isNull()
assertThat(room.newTopic).isNull() assertThat(room.newTopic).isNull()
assertThat(room.newAvatarData).isNull() assertThat(room.newAvatarData).isNull()
assertThat(room.removedAvatar).isFalse() assertThat(room.removedAvatar).isFalse()
assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java) assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java)
} }
} }
@Test @Test
fun `present - sets save action to failure if name update fails`() = runTest { fun `present - sets save action to failure if name update fails`() = runTest {
val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL).apply { val room = aMatrixRoom(
topic = "My topic",
displayName = "Name",
avatarUrl = AN_AVATAR_URL,
emitRoomInfo = true,
).apply {
givenSetNameResult(Result.failure(Throwable("!"))) givenSetNameResult(Result.failure(Throwable("!")))
} }
saveAndAssertFailure(room, RoomDetailsEditEvents.UpdateRoomName("New name")) saveAndAssertFailure(room, RoomDetailsEditEvents.UpdateRoomName("New name"))
} }
@Test @Test
fun `present - sets save action to failure if topic update fails`() = runTest { fun `present - sets save action to failure if topic update fails`() = runTest {
val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL).apply { val room = aMatrixRoom(
topic = "My topic",
displayName = "Name",
avatarUrl = AN_AVATAR_URL,
emitRoomInfo = true,
).apply {
givenSetTopicResult(Result.failure(Throwable("!"))) givenSetTopicResult(Result.failure(Throwable("!")))
} }
saveAndAssertFailure(room, RoomDetailsEditEvents.UpdateRoomTopic("New topic")) saveAndAssertFailure(room, RoomDetailsEditEvents.UpdateRoomTopic("New topic"))
} }
@Test @Test
fun `present - sets save action to failure if removing avatar fails`() = runTest { fun `present - sets save action to failure if removing avatar fails`() = runTest {
val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL).apply { val room = aMatrixRoom(
topic = "My topic",
displayName = "Name",
avatarUrl = AN_AVATAR_URL,
emitRoomInfo = true,
).apply {
givenRemoveAvatarResult(Result.failure(Throwable("!"))) givenRemoveAvatarResult(Result.failure(Throwable("!")))
} }
saveAndAssertFailure(room, RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove)) saveAndAssertFailure(room, RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove))
} }
@Test @Test
fun `present - sets save action to failure if setting avatar fails`() = runTest { fun `present - sets save action to failure if setting avatar fails`() = runTest {
givenPickerReturnsFile() givenPickerReturnsFile()
val room = aMatrixRoom(
val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL).apply { topic = "My topic",
displayName = "Name",
avatarUrl = AN_AVATAR_URL,
emitRoomInfo = true,
).apply {
givenUpdateAvatarResult(Result.failure(Throwable("!"))) givenUpdateAvatarResult(Result.failure(Throwable("!")))
} }
saveAndAssertFailure(room, RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) saveAndAssertFailure(room, RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
} }
@Test @Test
fun `present - CancelSaveChanges resets save action state`() = runTest { fun `present - CancelSaveChanges resets save action state`() = runTest {
givenPickerReturnsFile() givenPickerReturnsFile()
val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL).apply { val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL).apply {
givenSetTopicResult(Result.failure(Throwable("!"))) givenSetTopicResult(Result.failure(Throwable("!")))
} }
val presenter = createRoomDetailsEditPresenter(room) val presenter = createRoomDetailsEditPresenter(room)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("foo")) initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("foo"))
initialState.eventSink(RoomDetailsEditEvents.Save) initialState.eventSink(RoomDetailsEditEvents.Save)
skipItems(2) skipItems(2)
assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java) assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java)
initialState.eventSink(RoomDetailsEditEvents.CancelSaveChanges) initialState.eventSink(RoomDetailsEditEvents.CancelSaveChanges)
assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Uninitialized::class.java) assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
} }
@ -602,16 +582,13 @@ class RoomDetailsEditPresenterTest {
private suspend fun saveAndAssertFailure(room: MatrixRoom, event: RoomDetailsEditEvents) { private suspend fun saveAndAssertFailure(room: MatrixRoom, event: RoomDetailsEditEvents) {
val presenter = createRoomDetailsEditPresenter(room) val presenter = createRoomDetailsEditPresenter(room)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitFirstItem()
initialState.eventSink(event) initialState.eventSink(event)
initialState.eventSink(RoomDetailsEditEvents.Save) initialState.eventSink(RoomDetailsEditEvents.Save)
skipItems(1) skipItems(1)
assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Loading::class.java) assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Loading::class.java)
assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java) assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java)
} }
@ -622,7 +599,6 @@ class RoomDetailsEditPresenterTest {
val processedFile: File = mockk { val processedFile: File = mockk {
every { readBytes() } returns fakeFileContents every { readBytes() } returns fakeFileContents
} }
fakePickerProvider.givenResult(anotherAvatarUri) fakePickerProvider.givenResult(anotherAvatarUri)
fakeMediaPreProcessor.givenResult( fakeMediaPreProcessor.givenResult(
Result.success( Result.success(
@ -638,3 +614,8 @@ class RoomDetailsEditPresenterTest {
private const val ANOTHER_AVATAR_URL = "example://camera/foo.jpg" private const val ANOTHER_AVATAR_URL = "example://camera/foo.jpg"
} }
} }
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
skipItems(2)
return awaitItem()
}

View file

@ -0,0 +1,243 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomdetails.edit
import androidx.activity.ComponentActivity
import androidx.annotation.StringRes
import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.assertHasNoClickAction
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditEvents
import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditState
import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditView
import io.element.android.features.roomdetails.impl.edit.aRoomDetailsEditState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack
import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class RoomDetailsEditViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking on back invoke back callback`() {
val eventsRecorder = EventsRecorder<RoomDetailsEditEvents>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setRoomDetailsEditView(
aRoomDetailsEditState(
eventSink = eventsRecorder
),
onBackPressed = callback,
)
rule.pressBack()
}
}
@Test
fun `when edition is successful, the expected callback is invoked`() {
val eventsRecorder = EventsRecorder<RoomDetailsEditEvents>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setRoomDetailsEditView(
aRoomDetailsEditState(
eventSink = eventsRecorder,
saveAction = AsyncAction.Success(Unit)
),
onRoomEdited = callback,
)
}
}
@Test
fun `when name is changed, the expected Event is emitted`() {
val eventsRecorder = EventsRecorder<RoomDetailsEditEvents>()
rule.setRoomDetailsEditView(
aRoomDetailsEditState(
eventSink = eventsRecorder,
roomRawName = "Marketing",
),
)
rule.onNodeWithText("Marketing").assertHasClickAction()
rule.onNodeWithText("Marketing").performTextInput("A")
eventsRecorder.assertSingle(RoomDetailsEditEvents.UpdateRoomName("AMarketing"))
}
@Test
fun `when user cannot change name, nothing happen`() {
val eventsRecorder = EventsRecorder<RoomDetailsEditEvents>(expectEvents = false)
rule.setRoomDetailsEditView(
aRoomDetailsEditState(
eventSink = eventsRecorder,
roomRawName = "Marketing",
canChangeName = false,
),
)
rule.onNodeWithText("Marketing").assertHasNoClickAction()
}
@Test
fun `when topic is changed, the expected Event is emitted`() {
val eventsRecorder = EventsRecorder<RoomDetailsEditEvents>()
rule.setRoomDetailsEditView(
aRoomDetailsEditState(
eventSink = eventsRecorder,
roomTopic = "My Topic",
),
)
rule.onNodeWithText("My Topic").assertHasClickAction()
rule.onNodeWithText("My Topic").performTextInput("A")
eventsRecorder.assertSingle(RoomDetailsEditEvents.UpdateRoomTopic("AMy Topic"))
}
@Test
fun `when user cannot change topic, nothing happen`() {
val eventsRecorder = EventsRecorder<RoomDetailsEditEvents>(expectEvents = false)
rule.setRoomDetailsEditView(
aRoomDetailsEditState(
eventSink = eventsRecorder,
roomTopic = "My Topic",
canChangeTopic = false,
),
)
rule.onNodeWithText("My Topic").assertHasNoClickAction()
}
@Ignore("This test is failing because the bottom sheet does not open")
@Test
fun `when avatar is changed with action to take photo, the expected Event is emitted`() {
testAvatarChange(
stringActionRes = CommonStrings.action_take_photo,
expectedEvent = RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.TakePhoto),
)
}
@Ignore("This test is failing because the bottom sheet does not open")
@Test
fun `when avatar is changed with action to choose photo, the expected Event is emitted`() {
testAvatarChange(
stringActionRes = CommonStrings.action_choose_photo,
expectedEvent = RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto),
)
}
@Ignore("This test is failing because the bottom sheet does not open")
@Test
fun `when avatar is changed with action to remove photo, the expected Event is emitted`() {
testAvatarChange(
stringActionRes = CommonStrings.action_remove,
expectedEvent = RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove),
)
}
private fun testAvatarChange(
@StringRes stringActionRes: Int,
expectedEvent: RoomDetailsEditEvents.HandleAvatarAction,
) {
val eventsRecorder = EventsRecorder<RoomDetailsEditEvents>()
rule.setRoomDetailsEditView(
aRoomDetailsEditState(
eventSink = eventsRecorder,
),
)
// Open the bottom sheet
rule.onNode(hasTestTag(TestTags.editAvatar.value)).performClick()
rule.onNodeWithText(rule.activity.getString(stringActionRes)).assertExists()
rule.clickOn(stringActionRes)
eventsRecorder.assertSingle(expectedEvent)
}
@Test
fun `when user cannot change avatar, nothing happen`() {
val eventsRecorder = EventsRecorder<RoomDetailsEditEvents>(expectEvents = false)
rule.setRoomDetailsEditView(
aRoomDetailsEditState(
eventSink = eventsRecorder,
canChangeAvatar = false,
),
)
rule.onNode(hasTestTag(TestTags.editAvatar.value)).performClick()
rule.onNodeWithText(rule.activity.getString(CommonStrings.action_take_photo)).assertDoesNotExist()
}
@Test
fun `when save is clicked, the expected Event is emitted`() {
val eventsRecorder = EventsRecorder<RoomDetailsEditEvents>()
rule.setRoomDetailsEditView(
aRoomDetailsEditState(
eventSink = eventsRecorder,
saveButtonEnabled = true,
),
)
rule.clickOn(CommonStrings.action_save)
eventsRecorder.assertSingle(RoomDetailsEditEvents.Save)
}
@Test
fun `when save is clicked, but nothing need to be saved, nothing happens`() {
val eventsRecorder = EventsRecorder<RoomDetailsEditEvents>(expectEvents = false)
rule.setRoomDetailsEditView(
aRoomDetailsEditState(
eventSink = eventsRecorder,
saveButtonEnabled = false,
),
)
rule.clickOn(CommonStrings.action_save)
}
@Test
fun `when error is shown, closing the dialog emit the expected Event`() {
val eventsRecorder = EventsRecorder<RoomDetailsEditEvents>()
rule.setRoomDetailsEditView(
aRoomDetailsEditState(
eventSink = eventsRecorder,
saveAction = AsyncAction.Failure(Throwable("Whelp")),
),
)
rule.clickOn(CommonStrings.action_ok)
eventsRecorder.assertSingle(RoomDetailsEditEvents.CancelSaveChanges)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomDetailsEditView(
state: RoomDetailsEditState,
onBackPressed: () -> Unit = EnsureNeverCalled(),
onRoomEdited: () -> Unit = EnsureNeverCalled(),
) {
setContent {
RoomDetailsEditView(
state = state,
onBackPressed = onBackPressed,
onRoomEdited = onRoomEdited,
)
}
}

View file

@ -1,132 +0,0 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// This is actually expected, as we should remove this component soon and use ModalBottomSheet instead
@file:Suppress("UsingMaterialAndMaterial3Libraries")
package io.element.android.libraries.designsystem.theme.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ModalBottomSheetDefaults
import androidx.compose.material.ModalBottomSheetState
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.rememberModalBottomSheetState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
import io.element.android.libraries.designsystem.modifiers.applyIf
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.PreviewGroup
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun ModalBottomSheetLayout(
sheetContent: @Composable ColumnScope.() -> Unit,
modifier: Modifier = Modifier,
sheetState: ModalBottomSheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden),
sheetShape: Shape = MaterialTheme.shapes.large.copy(bottomStart = CornerSize(0.dp), bottomEnd = CornerSize(0.dp)),
sheetElevation: Dp = ModalBottomSheetDefaults.Elevation,
sheetBackgroundColor: Color = MaterialTheme.colorScheme.surface,
sheetContentColor: Color = contentColorFor(sheetBackgroundColor),
scrimColor: Color = ModalBottomSheetDefaults.scrimColor,
displayHandle: Boolean = false,
useSystemPadding: Boolean = true,
content: @Composable () -> Unit = {}
) {
androidx.compose.material.ModalBottomSheetLayout(
sheetContent = {
Column(
Modifier.fillMaxWidth()
.applyIf(useSystemPadding, ifTrue = {
navigationBarsPadding()
})
) {
if (displayHandle) {
Spacer(modifier = Modifier.height(16.dp))
Box(
modifier = Modifier
.background(MaterialTheme.colorScheme.onSurfaceVariant, RoundedCornerShape(2.dp))
.size(width = 32.dp, height = 4.dp)
.align(Alignment.CenterHorizontally),
)
Spacer(modifier = Modifier.height(24.dp))
}
sheetContent()
}
},
modifier = modifier,
sheetState = sheetState,
sheetShape = sheetShape,
sheetElevation = sheetElevation,
sheetBackgroundColor = sheetBackgroundColor,
sheetContentColor = sheetContentColor,
scrimColor = scrimColor,
content = content,
)
}
@Preview(group = PreviewGroup.BottomSheets)
@Composable
internal fun ModalBottomSheetLayoutLightPreview() =
ElementPreviewLight { ContentToPreview() }
@Preview(group = PreviewGroup.BottomSheets)
@Composable
internal fun ModalBottomSheetLayoutDarkPreview() =
ElementPreviewDark { ContentToPreview() }
@OptIn(ExperimentalMaterialApi::class)
@ExcludeFromCoverage
@Composable
private fun ContentToPreview() {
ModalBottomSheetLayout(
modifier = Modifier.height(140.dp),
displayHandle = true,
sheetState = ModalBottomSheetState(ModalBottomSheetValue.Expanded, density = LocalDensity.current),
sheetContent = {
Text(
text = "Sheet Content",
modifier = Modifier
.padding(start = 16.dp, end = 16.dp, bottom = 20.dp)
.background(color = Color.Green)
)
}
) {
Text(text = "Content", modifier = Modifier.background(color = Color.Red))
}
}

View file

@ -27,7 +27,10 @@ import kotlinx.collections.immutable.ImmutableMap
@Immutable @Immutable
data class MatrixRoomInfo( data class MatrixRoomInfo(
val id: RoomId, val id: RoomId,
/** The room's name from the room state event if received from sync, or one that's been computed otherwise. */
val name: String?, val name: String?,
/** Room name as defined by the room state event only. */
val rawName: String?,
val topic: String?, val topic: String?,
val avatarUrl: String?, val avatarUrl: String?,
val isDirect: Boolean, val isDirect: Boolean,

View file

@ -39,6 +39,7 @@ class MatrixRoomInfoMapper(
return MatrixRoomInfo( return MatrixRoomInfo(
id = RoomId(it.id), id = RoomId(it.id),
name = it.displayName, name = it.displayName,
rawName = it.rawName,
topic = it.topic, topic = it.topic,
avatarUrl = it.avatarUrl, avatarUrl = it.avatarUrl,
isDirect = it.isDirect, isDirect = it.isDirect,

View file

@ -56,6 +56,7 @@ val A_TRANSACTION_ID = TransactionId("aTransactionId")
const val A_UNIQUE_ID = "aUniqueId" const val A_UNIQUE_ID = "aUniqueId"
const val A_ROOM_NAME = "A room name" const val A_ROOM_NAME = "A room name"
const val A_ROOM_RAW_NAME = "A room raw name"
const val A_MESSAGE = "Hello world!" const val A_MESSAGE = "Hello world!"
const val A_REPLY = "OK, I'll be there!" const val A_REPLY = "OK, I'll be there!"
const val ANOTHER_MESSAGE = "Hello universe!" const val ANOTHER_MESSAGE = "Hello universe!"

View file

@ -735,6 +735,7 @@ data class EndPollInvocation(
fun aRoomInfo( fun aRoomInfo(
id: RoomId = A_ROOM_ID, id: RoomId = A_ROOM_ID,
name: String? = A_ROOM_NAME, name: String? = A_ROOM_NAME,
rawName: String? = name,
topic: String? = "A topic", topic: String? = "A topic",
avatarUrl: String? = AN_AVATAR_URL, avatarUrl: String? = AN_AVATAR_URL,
isDirect: Boolean = false, isDirect: Boolean = false,
@ -759,6 +760,7 @@ fun aRoomInfo(
) = MatrixRoomInfo( ) = MatrixRoomInfo(
id = id, id = id,
name = name, name = name,
rawName = rawName,
topic = topic, topic = topic,
avatarUrl = avatarUrl, avatarUrl = avatarUrl,
isDirect = isDirect, isDirect = isDirect,

View file

@ -39,6 +39,7 @@ dependencies {
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.testtags)
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(libs.coil.gif) implementation(libs.coil.gif)
implementation(libs.jsoup) implementation(libs.jsoup)

View file

@ -14,25 +14,23 @@
* limitations under the License. * limitations under the License.
*/ */
@file:OptIn(ExperimentalMaterialApi::class) @file:OptIn(ExperimentalMaterial3Api::class)
@file:Suppress("UsingMaterialAndMaterial3Libraries")
package io.element.android.libraries.matrix.ui.components package io.element.android.libraries.matrix.ui.components
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material.ModalBottomSheetState
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.components.list.ListItemContent
@ -41,33 +39,44 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.IconSource import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.ListItem import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ListItemStyle import io.element.android.libraries.designsystem.theme.components.ListItemStyle
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheetLayout import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
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.hide
import io.element.android.libraries.matrix.ui.media.AvatarAction import io.element.android.libraries.matrix.ui.media.AvatarAction
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AvatarActionBottomSheet( fun AvatarActionBottomSheet(
actions: ImmutableList<AvatarAction>, actions: ImmutableList<AvatarAction>,
modalBottomSheetState: ModalBottomSheetState, isVisible: Boolean,
onActionSelected: (action: AvatarAction) -> Unit, onActionSelected: (action: AvatarAction) -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
fun onItemActionClicked(itemAction: AvatarAction) { val sheetState = rememberModalBottomSheetState(
onActionSelected(itemAction) skipPartiallyExpanded = true
coroutineScope.launch { )
modalBottomSheetState.hide()
} BackHandler(enabled = isVisible) {
sheetState.hide(coroutineScope, then = { onDismiss() })
} }
ModalBottomSheetLayout( fun onItemActionClicked(itemAction: AvatarAction) {
modifier = modifier, onActionSelected(itemAction)
sheetState = modalBottomSheetState, sheetState.hide(coroutineScope, then = { onDismiss() })
displayHandle = true, }
sheetContent = {
if (isVisible) {
ModalBottomSheet(
onDismissRequest = {
sheetState.hide(coroutineScope, then = { onDismiss() })
},
modifier = modifier,
sheetState = sheetState,
) {
AvatarActionBottomSheetContent( AvatarActionBottomSheetContent(
actions = actions, actions = actions,
onActionClicked = ::onItemActionClicked, onActionClicked = ::onItemActionClicked,
@ -76,7 +85,7 @@ fun AvatarActionBottomSheet(
.imePadding() .imePadding()
) )
} }
) }
} }
@Composable @Composable
@ -115,10 +124,8 @@ private fun AvatarActionBottomSheetContent(
internal fun AvatarActionBottomSheetPreview() = ElementPreview { internal fun AvatarActionBottomSheetPreview() = ElementPreview {
AvatarActionBottomSheet( AvatarActionBottomSheet(
actions = persistentListOf(AvatarAction.TakePhoto, AvatarAction.ChoosePhoto, AvatarAction.Remove), actions = persistentListOf(AvatarAction.TakePhoto, AvatarAction.ChoosePhoto, AvatarAction.Remove),
modalBottomSheetState = ModalBottomSheetState( isVisible = true,
initialValue = ModalBottomSheetValue.Expanded,
density = LocalDensity.current,
),
onActionSelected = { }, onActionSelected = { },
onDismiss = { },
) )
} }

View file

@ -33,23 +33,32 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
@Composable @Composable
fun EditableAvatarView( fun EditableAvatarView(
userId: String?, matrixId: String,
displayName: String?, displayName: String?,
avatarUrl: Uri?, avatarUrl: Uri?,
avatarSize: AvatarSize, avatarSize: AvatarSize,
onAvatarClicked: () -> Unit, onAvatarClicked: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column(modifier = modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box( Box(
modifier = Modifier modifier = Modifier
.size(avatarSize.dp) .size(avatarSize.dp)
@ -58,15 +67,14 @@ fun EditableAvatarView(
onClick = onAvatarClicked, onClick = onAvatarClicked,
indication = rememberRipple(bounded = false), indication = rememberRipple(bounded = false),
) )
.testTag(TestTags.editAvatar)
) { ) {
when (avatarUrl?.scheme) { when (avatarUrl?.scheme) {
null, "mxc" -> { null, "mxc" -> {
userId?.let { Avatar(
Avatar( avatarData = AvatarData(matrixId, displayName, avatarUrl?.toString(), size = avatarSize),
avatarData = AvatarData(it, displayName, avatarUrl?.toString(), size = avatarSize), modifier = Modifier.fillMaxSize(),
modifier = Modifier.fillMaxSize(), )
)
}
} }
else -> { else -> {
UnsavedAvatar( UnsavedAvatar(
@ -94,3 +102,26 @@ fun EditableAvatarView(
} }
} }
} }
@PreviewsDayNight
@Composable
internal fun EditableAvatarViewPreview(
@PreviewParameter(EditableAvatarViewUriProvider::class) uri: Uri?
) = ElementPreview {
EditableAvatarView(
matrixId = "id",
displayName = "A room",
avatarUrl = uri,
avatarSize = AvatarSize.EditRoomDetails,
onAvatarClicked = {},
)
}
open class EditableAvatarViewUriProvider : PreviewParameterProvider<Uri?> {
override val values: Sequence<Uri?>
get() = sequenceOf(
null,
Uri.parse("mxc://matrix.org/123456"),
Uri.parse("https://example.com/avatar.jpg"),
)
}

View file

@ -62,3 +62,21 @@ fun MatrixRoom.isOwnUserAdmin(): Boolean {
val powerLevel = roomInfo?.userPowerLevels?.get(sessionId) ?: 0L val powerLevel = roomInfo?.userPowerLevels?.get(sessionId) ?: 0L
return RoomMember.Role.forPowerLevel(powerLevel) == RoomMember.Role.ADMIN return RoomMember.Role.forPowerLevel(powerLevel) == RoomMember.Role.ADMIN
} }
@Composable
fun MatrixRoom.rawName(): String? {
val roomInfo by roomInfoFlow.collectAsState(initial = null)
return roomInfo?.rawName
}
@Composable
fun MatrixRoom.topic(): String? {
val roomInfo by roomInfoFlow.collectAsState(initial = null)
return roomInfo?.topic
}
@Composable
fun MatrixRoom.avatarUrl(): String? {
val roomInfo by roomInfoFlow.collectAsState(initial = null)
return roomInfo?.avatarUrl
}

View file

@ -64,6 +64,11 @@ object TestTags {
*/ */
val memberDetailAvatar = TestTag("member_detail-avatar") val memberDetailAvatar = TestTag("member_detail-avatar")
/**
* Edit avatar.
*/
val editAvatar = TestTag("edit-avatar")
/** /**
* Welcome screen. * Welcome screen.
*/ */

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:0aebfeacf46df5674e4b159fd9dca029288596a2835ff953783d144cbb44c91f oid sha256:ba10d755e7cefd5946a7519216a6298a1a17cbe80423bff3939e8699cc5531a6
size 61153 size 60967

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:2fe73b4f444fd4d42d99de9705ca5a73ee95cc954ab39ec874208277dac9b131 oid sha256:5d2e65da53dd78db7204ffaebc8823e23f6517cdf67fcec044627a6d740361cb
size 84224 size 86948

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d8400f3b6385a3eb0096282b2e8a64d7d593ac51deb2c1a32d55acbbce430244 oid sha256:3609875b66402a3cd7db798005aafb87479b07056f6e8f737cede80950a33665
size 57700 size 57693

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:495521b5b0d4cb90ebd5be62975673965bd103c5bba12c43c3c6b2d6003acbfb oid sha256:5fc8ac21d7e0e9d15b59f4bc9237dbafd974aba8bd26bd67192b04c1e3c5116c
size 81045 size 83702

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:984d249036bb82d31cfc7d183b0a33c6419f0827e535a79390055f574e36c89a oid sha256:b0ab108a6bc88c7a811e9589596aa41be765005086863a31eea31bf3cd651aad
size 22542 size 22351

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:4bec5a025508cfff5808b3c007dcb31d780635a208d959f05891538e7dd0bba2 oid sha256:82b3c70a99cad62e1cfd4cee89fa96e514f1f0b6418dcf857fcd4981e32a3ba6
size 20896 size 20885

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f238794e2139509c701d363b90ae42cd0ad127e09665d82339e6d292c04c103e oid sha256:f2c5b052dff4115a1b9b829e68fd46edf865994929a1e8ae0b463f168ef03907
size 30221 size 30399

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:8afcab5de05d583820291a8e4578a1799960cf96137bfdae46542504f6a02a06 oid sha256:3c5a165e3c5c81755b739623b872bf1b1b99d6ccd8549a1c2324e59e9c421b58
size 23555 size 23688

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:6c7a63959ed475c3aca89125149eb66b87702fdad9e7162fda52341bb1069eca oid sha256:7eb57ad86edca68df0129f25221758647d1edab14e233e4e10f9ad1c0750e5d1
size 55870 size 31743

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e0e895eee0d379ccff79d44f2ae3e954bbe23597f11086cdfb99c3b0981e392a oid sha256:3fdd4a80c5a8499d3b79617c1757358de7f40a1fdb43d1c537ce4170af5efb9a
size 29939 size 55730

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:acb90cc495ca721862557a0f213d80b302853309398550553bdbec0984975769 oid sha256:8268170c93b3ec77e9222e926a1396f9889922fe4892d6388d2de015aee94c47
size 29940 size 30088

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:c2beb630f1cafc363da0d1d7f508ad66b7738f0624d9cceefb7db35c12787b36 oid sha256:2bc28e45b7d804766d5c0ce0cf217bbdb81459ad19415739835c849d8638c634
size 30153 size 30121

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:c8b761beaa0856819a39931fbd57f12a22c30b599d545f59b21b92d0d15ab0eb oid sha256:f504a2811ed252564a9c14cf7d9c770d2c7993f4e839c5e4e506e555cb4258fc
size 27563 size 30310

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a01beba7ac836b5fdc66687298ced0a81d260fe1af3b3260b468226b90b8df48
size 27695

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:cea255e3114cc3e854d9f831cd7ea646f3aa9992d3a56e81bb4a5a77a2ecf66b oid sha256:f2eb8e41704492332da26e123a48f423a73104532e947246faf73604e7d54a2c
size 28877 size 28844

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9eac310d1a3e7d0451ca0c21bcceeaab9c909b174a9040f31fd48a3b566673a4 oid sha256:deb4b44dcd728ef7b7cef621dd67ac38087922aeeb6efffe3ab5b9ec9aa08b51
size 22568 size 22554

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:fde4132005b1c340aff35cb703d1b2cbd43d0cdea9a552f90b7e124a909138c8 oid sha256:62ee10256d36d435b0604e5014725de6eb4085d56390ece44852f0941115aebb
size 54156 size 30092

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:5828dfd60d097c0d571f95c977c3b573ccb0853a50dca736b0024037dbfd7d66 oid sha256:df342f6121b20e20c52bf3464be4cf6880607782d046009cc8af0e6ea3ac4644
size 27797 size 54149

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f2bd04174660f32479aff938b2e9c19a7ddf2be8b90b83f51d775bacf5a5f187 oid sha256:8fd504b944225a0b95bd041bde48865cc6b25f3641c70db32aa9f30e5c532975
size 28584 size 27772

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:7d6506e709ce97294d263032aa932eee53356c01b769f0ea0b6dc322ae7da393 oid sha256:1da3677f03a01306116ae83dfd54e4ad29f414082be5611816411616b58bfe85
size 28026 size 28556

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:58c14e6556999a85fde1f898c2fb562fc403f38d9c287f3db44b9f0fb29496cc oid sha256:d5c26e007cdc2e7d47f79d662c64dae6ee4710e679766bc31f5eb20cfe8ddf57
size 24255 size 28023

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:dcb0135702ee7d8aeea60042289d30111c8b9b0d980860347f23f6ee37bd8c32
size 24258

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:761e809239fb9f3610e3c1760e999dd6129708e7ed41208c3eeb61f0dc0bc2bb
size 10863

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:898b5ca8642123df13f95b0cbdc9252d6072be9b033e9bd227c32a9703328883
size 10972

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:79d5851c4aac88fcf826cd9c1b114df2b82d7db132c34908f46696059404209d oid sha256:e1938c8062f0a198cbf5bdb17e24f25c76dd8e8b6efcec719cc4d996b4a18b73
size 15176 size 15291

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:0c2a1aaa27a9013d7f171e8463b3b32f19ce3be4f4094657a4c3f5c1e25aa88c oid sha256:f5d21fae9b198a9b7aef195938748ea5e06db9cbed397baa6cdbeb0a341393e5
size 13263 size 13485

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:befba1f1514fcf2f3a69b5f423fc66be7df252d133bff7fcda519c9f6acd4e8b
size 8152

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1f9f0c4e62431e9aa5543f28f2784ae3e245cde22ed2745f15ba3fe7be2f8ef0
size 10287

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:fcb18ec6ab064604201838b3a1497dd236731c61cc85d7be937071870dfeb5d6
size 34082

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3eae0336bcaf91c668a8a26331718085b282f335ad85a0b5eaa0b2c24d890627
size 8424

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c2da272b8bc76f7d6dc3b79bdae73014d831c0fd225f830d6e15eaf4c8060737
size 10435

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:36dc08ee4cb55da521a2577715de85049ccfde5c9ed10db248fc2cd8418084f9
size 33745