Update room properties from room details (#439)

-  Add the edit action in the room details
-  Add "Add topic" button in room details
-  Add the screen behind that action to edit some room properties: avatar, name, topic
   -  Handle the save button action
      - enable the button only if changes are detected
      - display a loader "updating room"
      - display an error dialog if any request has failed
- Check user has the right power level to change various attributes
   - "Add topic" is only shown if there's no topic and they are able to set on
   - Edit menu is only shown if they can change topic, name or avatar
   - On the edit page, any fields they can't change are uneditable

Co-authored-by: Chris Smith <csmith@lunarian.uk>
This commit is contained in:
Florian Renaud
2023-06-01 17:10:29 +02:00
committed by GitHub
parent 09411f8993
commit 04d4b6369a
100 changed files with 2031 additions and 219 deletions

1
changelog.d/242.feature Normal file
View File

@@ -0,0 +1 @@
[Create and join rooms] Update room properties from room details

View File

@@ -48,8 +48,8 @@ dependencies {
implementation(projects.libraries.androidutils)
implementation(projects.libraries.mediapickers.api)
implementation(projects.libraries.mediaupload.api)
implementation(libs.coil.compose)
implementation(projects.libraries.usersearch.impl)
implementation(libs.coil.compose)
api(projects.features.createroom.api)
testImplementation(libs.test.junit)

View File

@@ -17,8 +17,8 @@
package io.element.android.features.createroom.impl.configureroom
import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.features.createroom.impl.configureroom.avatar.AvatarAction
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.media.AvatarAction
sealed interface ConfigureRoomEvents {
data class RoomNameChanged(val name: String) : ConfigureRoomEvents

View File

@@ -27,7 +27,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.features.createroom.impl.CreateRoomDataStore
import io.element.android.features.createroom.impl.configureroom.avatar.AvatarAction
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.execute
@@ -37,6 +36,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
import io.element.android.libraries.matrix.api.createroom.RoomPreset
import io.element.android.libraries.matrix.api.createroom.RoomVisibility
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import kotlinx.collections.immutable.toImmutableList

View File

@@ -16,8 +16,8 @@
package io.element.android.features.createroom.impl.configureroom
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.features.createroom.impl.configureroom.avatar.AvatarAction
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.collections.immutable.ImmutableList

View File

@@ -17,6 +17,7 @@
package io.element.android.features.createroom.impl.configureroom
import android.net.Uri
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -24,9 +25,11 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.rememberModalBottomSheetState
@@ -46,11 +49,9 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.features.createroom.impl.R
import io.element.android.features.createroom.impl.components.Avatar
import io.element.android.features.createroom.impl.components.LabelledTextField
import io.element.android.features.createroom.impl.components.RoomPrivacyOption
import io.element.android.features.createroom.impl.configureroom.avatar.AvatarActionListView
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.components.LabelledTextField
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
@@ -61,7 +62,9 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet
import io.element.android.libraries.matrix.ui.components.SelectedUsersList
import io.element.android.libraries.matrix.ui.components.UnsavedAvatar
import kotlinx.coroutines.launch
import io.element.android.libraries.ui.strings.R as StringR
@@ -105,54 +108,48 @@ fun ConfigureRoomView(
)
}
) { padding ->
LazyColumn(
Column(
modifier = Modifier
.padding(padding)
.imePadding()
.verticalScroll(rememberScrollState())
.consumeWindowInsets(padding),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
item {
RoomNameWithAvatar(
modifier = Modifier.padding(horizontal = 16.dp),
avatarUri = state.config.avatarUri,
roomName = state.config.roomName.orEmpty(),
onAvatarClick = ::onAvatarClicked,
onRoomNameChanged = { state.eventSink(ConfigureRoomEvents.RoomNameChanged(it)) },
)
}
item {
RoomTopic(
modifier = Modifier.padding(horizontal = 16.dp),
topic = state.config.topic.orEmpty(),
onTopicChanged = { state.eventSink(ConfigureRoomEvents.TopicChanged(it)) },
)
}
RoomNameWithAvatar(
modifier = Modifier.padding(horizontal = 16.dp),
avatarUri = state.config.avatarUri,
roomName = state.config.roomName.orEmpty(),
onAvatarClick = ::onAvatarClicked,
onRoomNameChanged = { state.eventSink(ConfigureRoomEvents.RoomNameChanged(it)) },
)
RoomTopic(
modifier = Modifier.padding(horizontal = 16.dp),
topic = state.config.topic.orEmpty(),
onTopicChanged = { state.eventSink(ConfigureRoomEvents.TopicChanged(it)) },
)
if (state.config.invites.isNotEmpty()) {
item {
SelectedUsersList(
contentPadding = PaddingValues(horizontal = 24.dp),
selectedUsers = state.config.invites,
onUserRemoved = {
focusManager.clearFocus()
state.eventSink(ConfigureRoomEvents.RemoveFromSelection(it))
},
)
}
}
item {
RoomPrivacyOptions(
modifier = Modifier.padding(bottom = 40.dp),
selected = state.config.privacy,
onOptionSelected = {
SelectedUsersList(
contentPadding = PaddingValues(horizontal = 24.dp),
selectedUsers = state.config.invites,
onUserRemoved = {
focusManager.clearFocus()
state.eventSink(ConfigureRoomEvents.RoomPrivacyChanged(it.privacy))
state.eventSink(ConfigureRoomEvents.RemoveFromSelection(it))
},
)
}
RoomPrivacyOptions(
modifier = Modifier.padding(bottom = 40.dp),
selected = state.config.privacy,
onOptionSelected = {
focusManager.clearFocus()
state.eventSink(ConfigureRoomEvents.RoomPrivacyChanged(it.privacy))
},
)
}
}
AvatarActionListView(
AvatarActionBottomSheet(
actions = state.avatarActions,
modalBottomSheetState = itemActionsBottomSheetState,
onActionSelected = { state.eventSink(ConfigureRoomEvents.HandleAvatarAction(it)) }
@@ -221,16 +218,17 @@ fun RoomNameWithAvatar(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Avatar(
UnsavedAvatar(
avatarUri = avatarUri,
onClick = onAvatarClick,
modifier = Modifier.clickable(onClick = onAvatarClick),
)
LabelledTextField(
label = stringResource(R.string.screen_create_room_room_name_label),
value = roomName,
placeholder = stringResource(R.string.screen_create_room_room_name_placeholder),
onValueChange = onRoomNameChanged
singleLine = true,
onValueChange = onRoomNameChanged,
)
}
}
@@ -269,6 +267,13 @@ fun RoomPrivacyOptions(
}
}
private fun Modifier.clearFocusOnTap(focusManager: FocusManager): Modifier =
pointerInput(Unit) {
detectTapGestures(onTap = {
focusManager.clearFocus()
})
}
@Preview
@Composable
fun ConfigureRoomViewLightPreview(@PreviewParameter(ConfigureRoomStateProvider::class) state: ConfigureRoomState) =
@@ -286,10 +291,3 @@ private fun ContentToPreview(state: ConfigureRoomState) {
)
}
private fun Modifier.clearFocusOnTap(focusManager: FocusManager): Modifier =
pointerInput(Unit) {
detectTapGestures(onTap = {
focusManager.clearFocus()
})
}

View File

@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"New room"</string>
<string name="screen_create_room_action_invite_people">"Invite people"</string>
<string name="screen_create_room_add_people_title">"Add people"</string>
<string name="screen_create_room_action_invite_people">"Invite friends to Element"</string>
<string name="screen_create_room_add_people_title">"Invite people"</string>
<string name="screen_create_room_error_creating_room">"An error occurred when creating the room"</string>
<string name="screen_create_room_private_option_description">"Messages in this room are encrypted. Encryption cant be disabled afterwards."</string>
<string name="screen_create_room_private_option_title">"Private room (invite only)"</string>

View File

@@ -21,9 +21,9 @@ import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.features.createroom.impl.CreateRoomDataStore
import io.element.android.features.createroom.impl.configureroom.avatar.AvatarAction
import io.element.android.features.createroom.impl.userlist.UserListDataStore
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.RoomId

View File

@@ -6,4 +6,4 @@
<string name="screen_invites_decline_direct_chat_title">"Decline chat"</string>
<string name="screen_invites_empty_list">"No Invites"</string>
<string name="screen_invites_invited_you">"%1$s invited you"</string>
</resources>
</resources>

View File

@@ -1,5 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_onboarding_sign_in_manually">"Sign in manually"</string>
<string name="screen_onboarding_sign_in_with_qr_code">"Sign in with QR code"</string>
<string name="screen_onboarding_sign_up">"Create account"</string>
<string name="screen_onboarding_subtitle">"Communicate and collaborate securely"</string>
<string name="screen_onboarding_welcome_subtitle">"Welcome to the %1$s Beta. Supercharged, for speed and simplicity."</string>
<string name="screen_onboarding_welcome_title">"Be in your Element"</string>
</resources>

View File

@@ -41,6 +41,8 @@ dependencies {
implementation(projects.libraries.elementresources)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.mediapickers.api)
implementation(projects.libraries.mediaupload.api)
api(projects.features.roomdetails.api)
api(projects.libraries.usersearch.api)
api(projects.services.apperror.api)
@@ -52,7 +54,10 @@ dependencies {
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.mockk)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.mediaupload.test)
testImplementation(projects.libraries.mediapickers.test)
testImplementation(projects.libraries.usersearch.test)
testImplementation(projects.tests.testutils)
testImplementation(projects.features.leaveroom.fake)

View File

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

View File

@@ -29,6 +29,7 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditNode
import io.element.android.features.roomdetails.impl.invite.RoomInviteMembersNode
import io.element.android.features.roomdetails.impl.members.RoomMemberListNode
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsNode
@@ -59,6 +60,9 @@ class RoomDetailsFlowNode @AssistedInject constructor(
@Parcelize
object RoomMemberList : NavTarget
@Parcelize
object RoomDetailsEdit : NavTarget
@Parcelize
object InviteMembers : NavTarget
@@ -74,12 +78,17 @@ class RoomDetailsFlowNode @AssistedInject constructor(
backstack.push(NavTarget.RoomMemberList)
}
override fun editRoomDetails() {
backstack.push(NavTarget.RoomDetailsEdit)
}
override fun openInviteMembers() {
backstack.push(NavTarget.InviteMembers)
}
}
createNode<RoomDetailsNode>(buildContext, listOf(roomDetailsCallback))
}
NavTarget.RoomMemberList -> {
val roomMemberListCallback = object : RoomMemberListNode.Callback {
override fun openRoomMemberDetails(roomMemberId: UserId) {
@@ -92,9 +101,15 @@ class RoomDetailsFlowNode @AssistedInject constructor(
}
createNode<RoomMemberListNode>(buildContext, listOf(roomMemberListCallback))
}
NavTarget.RoomDetailsEdit -> {
createNode<RoomDetailsEditNode>(buildContext)
}
NavTarget.InviteMembers -> {
createNode<RoomInviteMembersNode>(buildContext)
}
is NavTarget.RoomMemberDetails -> {
val plugins = listOf(RoomMemberDetailsNode.RoomMemberDetailsInput(navTarget.roomMemberId))
createNode<RoomMemberDetailsNode>(buildContext, plugins)

View File

@@ -46,6 +46,7 @@ class RoomDetailsNode @AssistedInject constructor(
interface Callback : Plugin {
fun openRoomMemberList()
fun openInviteMembers()
fun editRoomDetails()
}
private val callbacks = plugins<Callback>()
@@ -90,6 +91,10 @@ class RoomDetailsNode @AssistedInject constructor(
}
}
private fun onEditRoomDetails() {
callbacks.forEach { it.editRoomDetails() }
}
@Composable
override fun View(modifier: Modifier) {
val context = LocalContext.current
@@ -103,10 +108,18 @@ class RoomDetailsNode @AssistedInject constructor(
this.onShareMember(context, roomMember)
}
fun onActionClicked(action: RoomDetailsAction) {
when (action) {
RoomDetailsAction.Edit -> onEditRoomDetails()
RoomDetailsAction.AddTopic -> onEditRoomDetails()
}
}
RoomDetailsView(
state = state,
modifier = modifier,
goBack = this::navigateUp,
onActionClicked = ::onActionClicked,
onShareRoom = ::onShareRoom,
onShareMember = ::onShareMember,
openRoomMemberList = ::openRoomMemberList,

View File

@@ -33,6 +33,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
import javax.inject.Inject
@@ -52,10 +53,23 @@ class RoomDetailsPresenter @Inject constructor(
val membersState by room.membersStateFlow.collectAsState()
val memberCount by getMemberCount(membersState)
val canInvite by getCanInvite(membersState)
val canEditName by getCanSendStateEvent(membersState, StateEventType.ROOM_NAME)
val canEditAvatar by getCanSendStateEvent(membersState, StateEventType.ROOM_AVATAR)
val canEditTopic by getCanSendStateEvent(membersState, StateEventType.ROOM_TOPIC)
val dmMember by room.getDirectRoomMember(membersState)
val roomMemberDetailsPresenter = roomMemberDetailsPresenter(dmMember)
val roomType = getRoomType(dmMember)
val topicState = remember(canEditTopic, room.topic) {
val topic = room.topic
when {
!topic.isNullOrBlank() -> RoomTopicState.ExistingTopic(topic)
canEditTopic -> RoomTopicState.CanAddTopic
else -> RoomTopicState.Hidden
}
}
fun handleEvents(event: RoomDetailsEvent) {
when (event) {
is RoomDetailsEvent.LeaveRoom ->
@@ -70,10 +84,11 @@ class RoomDetailsPresenter @Inject constructor(
roomName = room.name ?: room.displayName,
roomAlias = room.alias,
roomAvatarUrl = room.avatarUrl,
roomTopic = room.topic,
roomTopic = topicState,
memberCount = memberCount,
isEncrypted = room.isEncrypted,
canInvite = canInvite,
canEdit = canEditAvatar || canEditName || canEditTopic,
roomType = roomType.value,
roomMemberDetailsState = roomMemberDetailsState,
leaveRoomState = leaveRoomState,
@@ -108,6 +123,15 @@ class RoomDetailsPresenter @Inject constructor(
return canInvite
}
@Composable
private fun getCanSendStateEvent(membersState: MatrixRoomMembersState, type: StateEventType): State<Boolean> {
val canSendEvent = remember(membersState) { mutableStateOf(false) }
LaunchedEffect(membersState) {
canSendEvent.value = room.canSendStateEvent(type).getOrElse { false }
}
return canSendEvent
}
@Composable
private fun getMemberCount(membersState: MatrixRoomMembersState): State<Async<Int>> {
return remember(membersState) {

View File

@@ -26,11 +26,12 @@ data class RoomDetailsState(
val roomName: String,
val roomAlias: String?,
val roomAvatarUrl: String?,
val roomTopic: String?,
val roomTopic: RoomTopicState,
val memberCount: Async<Int>,
val isEncrypted: Boolean,
val roomType: RoomDetailsType,
val roomMemberDetailsState: RoomMemberDetailsState?,
val canEdit: Boolean,
val canInvite: Boolean,
val leaveRoomState: LeaveRoomState,
val eventSink: (RoomDetailsEvent) -> Unit
@@ -40,3 +41,9 @@ sealed interface RoomDetailsType {
object Room : RoomDetailsType
data class Dm(val roomMember: RoomMember) : RoomDetailsType
}
sealed interface RoomTopicState {
object Hidden : RoomTopicState
object CanAddTopic : RoomTopicState
data class ExistingTopic(val topic: String) : RoomTopicState
}

View File

@@ -28,13 +28,15 @@ open class RoomDetailsStateProvider : PreviewParameterProvider<RoomDetailsState>
override val values: Sequence<RoomDetailsState>
get() = sequenceOf(
aRoomDetailsState(),
aRoomDetailsState().copy(roomTopic = null),
aRoomDetailsState().copy(roomTopic = RoomTopicState.Hidden),
aRoomDetailsState().copy(roomTopic = RoomTopicState.CanAddTopic),
aRoomDetailsState().copy(isEncrypted = false),
aRoomDetailsState().copy(roomAlias = null),
aRoomDetailsState().copy(memberCount = Async.Failure(Throwable())),
aDmRoomDetailsState().copy(roomName = "Daniel"),
aDmRoomDetailsState(isDmMemberIgnored = true).copy(roomName = "Daniel"),
aRoomDetailsState().copy(canInvite = true),
aRoomDetailsState().copy(canEdit = true),
// Add other state here
)
}
@@ -64,14 +66,17 @@ fun aRoomDetailsState() = RoomDetailsState(
roomName = "Marketing",
roomAlias = "#marketing:domain.com",
roomAvatarUrl = null,
roomTopic = "Welcome to #marketing, home of the Marketing team " +
"|| WIKI PAGE: https://domain.org/wiki/Marketing " +
"|| MAIL iki/Marketing " +
"|| MAI iki/Marketing " +
"|| MAI iki/Marketing...",
roomTopic = RoomTopicState.ExistingTopic(
"Welcome to #marketing, home of the Marketing team " +
"|| WIKI PAGE: https://domain.org/wiki/Marketing " +
"|| MAIL iki/Marketing " +
"|| MAI iki/Marketing " +
"|| MAI iki/Marketing..."
),
memberCount = Async.Success(32),
isEncrypted = true,
canInvite = false,
canEdit = false,
roomType = RoomDetailsType.Room,
roomMemberDetailsState = null,
leaveRoomState = LeaveRoomState(),

View File

@@ -28,16 +28,25 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.PersonAddAlt
import androidx.compose.material.icons.outlined.Share
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
@@ -63,24 +72,26 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.LargeHeightPreview
import io.element.android.libraries.designsystem.theme.LocalColors
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun RoomDetailsView(
state: RoomDetailsState,
goBack: () -> Unit,
onActionClicked: (RoomDetailsAction) -> Unit,
onShareRoom: () -> Unit,
onShareMember: (RoomMember) -> Unit,
openRoomMemberList: () -> Unit,
invitePeople: () -> Unit,
modifier: Modifier = Modifier,
) {
fun onShareMember() {
onShareMember((state.roomType as RoomDetailsType.Dm).roomMember)
}
@@ -88,13 +99,18 @@ fun RoomDetailsView(
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(title = { }, navigationIcon = { BackButton(onClick = goBack) })
RoomDetailsTopBar(
goBack = goBack,
showEdit = state.canEdit,
onActionClicked = onActionClicked
)
},
) { padding ->
Column(modifier = Modifier
.padding(padding)
.consumeWindowInsets(padding)
.verticalScroll(rememberScrollState())
Column(
modifier = Modifier
.padding(padding)
.verticalScroll(rememberScrollState())
.consumeWindowInsets(padding)
) {
LeaveRoomView(state = state.leaveRoomState)
@@ -108,6 +124,7 @@ fun RoomDetailsView(
)
MainActionsSection(onShareRoom = onShareRoom)
}
is RoomDetailsType.Dm -> {
val member = state.roomType.roomMember
RoomMemberHeaderSection(
@@ -120,8 +137,11 @@ fun RoomDetailsView(
}
Spacer(Modifier.height(26.dp))
if (state.roomTopic != null) {
TopicSection(roomTopic = state.roomTopic)
if (state.roomTopic !is RoomTopicState.Hidden) {
TopicSection(
roomTopic = state.roomTopic,
onActionClicked = onActionClicked,
)
}
if (state.roomType is RoomDetailsType.Room) {
@@ -129,10 +149,14 @@ fun RoomDetailsView(
MembersSection(
memberCount = memberCount,
isLoading = state.memberCount.isLoading(),
showInvite = state.canInvite,
openRoomMemberList = openRoomMemberList,
invitePeople = invitePeople,
)
if (state.canInvite) {
InviteSection(
invitePeople = invitePeople
)
}
}
if (state.isEncrypted) {
@@ -152,6 +176,45 @@ fun RoomDetailsView(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun RoomDetailsTopBar(
goBack: () -> Unit,
onActionClicked: (RoomDetailsAction) -> Unit,
showEdit: Boolean,
modifier: Modifier = Modifier,
) {
var showMenu by remember { mutableStateOf(false) }
TopAppBar(
modifier = modifier,
title = { },
navigationIcon = { BackButton(onClick = goBack) },
actions = {
if (showEdit) {
IconButton(onClick = { showMenu = !showMenu }) {
Icon(Icons.Default.MoreVert, "")
}
DropdownMenu(
modifier = Modifier.widthIn(200.dp),
expanded = showMenu,
onDismissRequest = { showMenu = false },
) {
DropdownMenuItem(
text = { Text(stringResource(id = StringR.string.action_edit)) },
onClick = {
// Explicitly close the menu before handling the action, as otherwise it stays open during the
// transition and renders really badly.
showMenu = false
onActionClicked(RoomDetailsAction.Edit)
},
)
}
}
},
)
}
@Composable
internal fun MainActionsSection(onShareRoom: () -> Unit, modifier: Modifier = Modifier) {
Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
@@ -185,14 +248,26 @@ internal fun RoomHeaderSection(
}
@Composable
internal fun TopicSection(roomTopic: String, modifier: Modifier = Modifier) {
internal fun TopicSection(
roomTopic: RoomTopicState,
onActionClicked: (RoomDetailsAction) -> Unit,
modifier: Modifier = Modifier
) {
PreferenceCategory(title = stringResource(StringR.string.common_topic), modifier = modifier) {
Text(
roomTopic,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 12.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.tertiary
)
if (roomTopic is RoomTopicState.CanAddTopic) {
PreferenceText(
title = stringResource(R.string.screen_room_details_add_topic_title),
icon = Icons.Outlined.Add,
onClick = { onActionClicked(RoomDetailsAction.AddTopic) },
)
} else if (roomTopic is RoomTopicState.ExistingTopic) {
Text(
roomTopic.topic,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 12.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.tertiary
)
}
}
}
@@ -200,8 +275,6 @@ internal fun TopicSection(roomTopic: String, modifier: Modifier = Modifier) {
internal fun MembersSection(
memberCount: Int?,
isLoading: Boolean,
showInvite: Boolean,
invitePeople: () -> Unit,
openRoomMemberList: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -213,13 +286,20 @@ internal fun MembersSection(
onClick = openRoomMemberList,
loadingCurrentValue = isLoading,
)
if (showInvite) {
PreferenceText(
title = stringResource(R.string.screen_room_details_invite_people_title),
icon = Icons.Outlined.PersonAddAlt,
onClick = invitePeople,
)
}
}
}
@Composable
internal fun InviteSection(
invitePeople: () -> Unit,
modifier: Modifier = Modifier,
) {
PreferenceCategory(modifier = modifier) {
PreferenceText(
title = stringResource(R.string.screen_room_details_invite_people_title),
icon = Icons.Outlined.PersonAddAlt,
onClick = invitePeople,
)
}
}
@@ -261,6 +341,7 @@ private fun ContentToPreview(state: RoomDetailsState) {
RoomDetailsView(
state = state,
goBack = {},
onActionClicked = {},
onShareRoom = {},
onShareMember = {},
openRoomMemberList = {},

View File

@@ -0,0 +1,27 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomdetails.impl.edit
import io.element.android.libraries.matrix.ui.media.AvatarAction
sealed interface RoomDetailsEditEvents {
data class HandleAvatarAction(val action: AvatarAction) : RoomDetailsEditEvents
data class UpdateRoomName(val name: String) : RoomDetailsEditEvents
data class UpdateRoomTopic(val topic: String) : RoomDetailsEditEvents
object Save : RoomDetailsEditEvents
object CancelSaveChanges : RoomDetailsEditEvents
}

View File

@@ -0,0 +1,46 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomdetails.impl.edit
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.RoomScope
@ContributesNode(RoomScope::class)
class RoomDetailsEditNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: RoomDetailsEditPresenter,
) : Node(buildContext, plugins = plugins) {
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
RoomDetailsEditView(
state = state,
onBackPressed = ::navigateUp,
onRoomEdited = ::navigateUp,
modifier = modifier,
)
}
}

View File

@@ -0,0 +1,166 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomdetails.impl.edit
import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.core.net.toUri
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.execute
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
class RoomDetailsEditPresenter @Inject constructor(
private val room: MatrixRoom,
private val mediaPickerProvider: PickerProvider,
private val mediaPreProcessor: MediaPreProcessor,
) : Presenter<RoomDetailsEditState> {
@Composable
override fun present(): RoomDetailsEditState {
val roomSyncUpdateFlow = room.syncUpdateFlow().collectAsState(0L)
// Since there is no way to obtain the new avatar uri after uploading a new avatar,
// just erase the local value when the room field has changed
var roomAvatarUri by rememberSaveable(room.avatarUrl) { mutableStateOf(room.avatarUrl?.toUri()) }
var roomName by rememberSaveable { mutableStateOf((room.name ?: room.displayName).trim()) }
var roomTopic by rememberSaveable { mutableStateOf(room.topic?.trim()) }
val saveButtonEnabled by remember(
roomSyncUpdateFlow.value,
roomName,
roomTopic,
roomAvatarUri,
) {
derivedStateOf {
roomAvatarUri?.toString()?.trim() != room.avatarUrl?.toUri()?.toString()?.trim()
|| roomName.trim() != (room.name ?: room.displayName).trim()
|| roomTopic.orEmpty().trim() != room.topic.orEmpty().trim()
}
}
var canChangeName by remember { mutableStateOf(false) }
var canChangeTopic by remember { mutableStateOf(false) }
var canChangeAvatar by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
canChangeName = room.canSendStateEvent(StateEventType.ROOM_NAME).getOrElse { false }
canChangeTopic = room.canSendStateEvent(StateEventType.ROOM_TOPIC).getOrElse { false }
canChangeAvatar = room.canSendStateEvent(StateEventType.ROOM_AVATAR).getOrElse { false }
}
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(
onResult = { uri -> if (uri != null) roomAvatarUri = uri }
)
val galleryImagePicker = mediaPickerProvider.registerGalleryImagePicker(
onResult = { uri -> if (uri != null) roomAvatarUri = uri }
)
val avatarActions by remember(roomAvatarUri) {
derivedStateOf {
listOfNotNull(
AvatarAction.TakePhoto,
AvatarAction.ChoosePhoto,
AvatarAction.Remove.takeIf { roomAvatarUri != null },
).toImmutableList()
}
}
val saveAction: MutableState<Async<Unit>> = remember { mutableStateOf(Async.Uninitialized) }
val localCoroutineScope = rememberCoroutineScope()
fun handleEvents(event: RoomDetailsEditEvents) {
when (event) {
is RoomDetailsEditEvents.Save -> localCoroutineScope.saveChanges(roomName, roomTopic, roomAvatarUri, saveAction)
is RoomDetailsEditEvents.HandleAvatarAction -> {
when (event.action) {
AvatarAction.ChoosePhoto -> galleryImagePicker.launch()
AvatarAction.TakePhoto -> cameraPhotoPicker.launch()
AvatarAction.Remove -> roomAvatarUri = null
}
}
is RoomDetailsEditEvents.UpdateRoomName -> roomName = event.name
is RoomDetailsEditEvents.UpdateRoomTopic -> roomTopic = event.topic.takeUnless { it.isEmpty() }
RoomDetailsEditEvents.CancelSaveChanges -> saveAction.value = Async.Uninitialized
}
}
return RoomDetailsEditState(
roomId = room.roomId.value,
roomName = roomName,
canChangeName = canChangeName,
roomTopic = roomTopic.orEmpty(),
canChangeTopic = canChangeTopic,
roomAvatarUrl = roomAvatarUri,
canChangeAvatar = canChangeAvatar,
avatarActions = avatarActions,
saveButtonEnabled = saveButtonEnabled,
saveAction = saveAction.value,
eventSink = ::handleEvents,
)
}
private fun CoroutineScope.saveChanges(name: String, topic: String?, avatarUri: Uri?, action: MutableState<Async<Unit>>) = launch {
val results = mutableListOf<Result<Unit>>()
suspend {
if (topic.orEmpty().trim() != room.topic.orEmpty().trim()) {
results.add(room.setTopic(topic.orEmpty()))
}
if (name.isNotEmpty() && name.trim() != room.name.orEmpty().trim()) {
results.add(room.setName(name))
}
if (avatarUri?.toString()?.trim() != room.avatarUrl?.trim()) {
results.add(updateAvatar(avatarUri))
}
if (results.all { it.isSuccess }) Unit else results.first { it.isFailure }.getOrThrow()
}.execute(action)
}
private suspend fun updateAvatar(avatarUri: Uri?): Result<Unit> {
return runCatching {
val result = if (avatarUri != null) {
val preprocessed = mediaPreProcessor.process(avatarUri, MimeTypes.Jpeg, compressIfPossible = false).getOrThrow() as? MediaUploadInfo.Image
val byteArray = preprocessed?.file?.readBytes()
byteArray?.let { room.updateAvatar(MimeTypes.Jpeg, it) } ?: error("Could not process the given uri ($avatarUri)")
} else {
room.removeAvatar()
}
result.getOrThrow()
}
}
}

View File

@@ -0,0 +1,36 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomdetails.impl.edit
import android.net.Uri
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.architecture.Async
import kotlinx.collections.immutable.ImmutableList
data class RoomDetailsEditState(
val roomId: String,
val roomName: String,
val canChangeName: Boolean,
val roomTopic: String,
val canChangeTopic: Boolean,
val roomAvatarUrl: Uri?,
val canChangeAvatar: Boolean,
val avatarActions: ImmutableList<AvatarAction>,
val saveButtonEnabled: Boolean,
val saveAction: Async<Unit>,
val eventSink: (RoomDetailsEditEvents) -> Unit
)

View File

@@ -0,0 +1,49 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomdetails.impl.edit
import android.net.Uri
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.Async
import kotlinx.collections.immutable.persistentListOf
open class RoomDetailsEditStateProvider : PreviewParameterProvider<RoomDetailsEditState> {
override val values: Sequence<RoomDetailsEditState>
get() = sequenceOf(
aRoomDetailsEditState(),
aRoomDetailsEditState().copy(roomTopic = ""),
aRoomDetailsEditState().copy(roomAvatarUrl = Uri.EMPTY),
aRoomDetailsEditState().copy(canChangeName = true, canChangeTopic = false, canChangeAvatar = true, saveButtonEnabled = false),
aRoomDetailsEditState().copy(canChangeName = false, canChangeTopic = true, canChangeAvatar = false, saveButtonEnabled = false),
aRoomDetailsEditState().copy(saveAction = Async.Loading()),
aRoomDetailsEditState().copy(saveAction = Async.Failure(Throwable("Whelp")))
)
}
fun aRoomDetailsEditState() = RoomDetailsEditState(
roomId = "a room id",
roomName = "Marketing",
canChangeName = true,
roomTopic = "a room topic that is quite long so should wrap onto multiple lines",
canChangeTopic = true,
roomAvatarUrl = null,
canChangeAvatar = true,
avatarActions = persistentListOf(),
saveButtonEnabled = true,
saveAction = Async.Uninitialized,
eventSink = {}
)

View File

@@ -0,0 +1,306 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
package io.element.android.features.roomdetails.impl.edit
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AddAPhoto
import androidx.compose.material.rememberModalBottomSheetState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusManager
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.features.roomdetails.impl.R
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.components.LabelledTextField
import io.element.android.libraries.designsystem.components.ProgressDialog
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.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.LocalColors
import io.element.android.libraries.designsystem.theme.components.CenterAlignedTopAppBar
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet
import io.element.android.libraries.matrix.ui.components.UnsavedAvatar
import kotlinx.coroutines.launch
import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class)
@Composable
fun RoomDetailsEditView(
state: RoomDetailsEditState,
onBackPressed: () -> Unit,
onRoomEdited: () -> Unit,
modifier: Modifier = Modifier,
) {
val coroutineScope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current
val itemActionsBottomSheetState = rememberModalBottomSheetState(
initialValue = ModalBottomSheetValue.Hidden,
)
fun onAvatarClicked() {
focusManager.clearFocus()
coroutineScope.launch {
itemActionsBottomSheetState.show()
}
}
Scaffold(
modifier = modifier.clearFocusOnTap(focusManager),
topBar = {
CenterAlignedTopAppBar(
title = {
Text(
text = stringResource(id = R.string.screen_room_details_edit_room_title),
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
)
},
navigationIcon = { BackButton(onClick = onBackPressed) },
actions = {
TextButton(
enabled = state.saveButtonEnabled,
onClick = {
focusManager.clearFocus()
state.eventSink(RoomDetailsEditEvents.Save)
},
) {
Text(
text = stringResource(StringR.string.action_save),
fontSize = 16.sp,
)
}
}
)
},
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.padding(horizontal = 16.dp)
.navigationBarsPadding()
.imePadding()
.verticalScroll(rememberScrollState())
) {
Spacer(modifier = Modifier.height(24.dp))
EditableAvatarView(state, ::onAvatarClicked)
Spacer(modifier = Modifier.height(60.dp))
if (state.canChangeName) {
LabelledTextField(
label = stringResource(id = R.string.screen_room_details_room_name_label),
value = state.roomName,
placeholder = stringResource(id = R.string.screen_room_details_room_name_placeholder),
singleLine = true,
onValueChange = { state.eventSink(RoomDetailsEditEvents.UpdateRoomName(it)) },
)
} else {
LabelledReadOnlyField(
title = stringResource(R.string.screen_room_details_room_name_label),
value = state.roomName
)
}
Spacer(modifier = Modifier.height(28.dp))
if (state.canChangeTopic) {
LabelledTextField(
label = stringResource(id = StringR.string.common_topic),
value = state.roomTopic,
placeholder = stringResource(id = R.string.screen_room_details_topic_placeholder),
maxLines = 10,
onValueChange = { state.eventSink(RoomDetailsEditEvents.UpdateRoomTopic(it)) },
)
} else {
LabelledReadOnlyField(
title = stringResource(R.string.screen_room_details_topic_title),
value = state.roomTopic
)
}
}
}
AvatarActionBottomSheet(
actions = state.avatarActions,
modalBottomSheetState = itemActionsBottomSheetState,
onActionSelected = { state.eventSink(RoomDetailsEditEvents.HandleAvatarAction(it)) }
)
when (state.saveAction) {
is Async.Loading -> {
ProgressDialog(text = stringResource(R.string.screen_room_details_updating_room))
}
is Async.Failure -> {
ErrorDialog(
content = stringResource(R.string.screen_room_details_edition_error),
onDismiss = { state.eventSink(RoomDetailsEditEvents.CancelSaveChanges) },
)
}
is Async.Success -> {
LaunchedEffect(state.saveAction) {
onRoomEdited()
}
}
else -> Unit
}
}
@Composable
private fun EditableAvatarView(
state: RoomDetailsEditState,
onAvatarClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier
.size(70.dp)
.clickable(onClick = onAvatarClicked, enabled = state.canChangeAvatar)
) {
// TODO this might be able to be simplified into a single component once send/receive media is done
when (state.roomAvatarUrl?.scheme) {
null, "mxc" -> {
Avatar(
avatarData = AvatarData(state.roomId, state.roomName, state.roomAvatarUrl?.toString(), size = AvatarSize.HUGE),
modifier = Modifier.fillMaxSize(),
)
}
else -> {
UnsavedAvatar(
avatarUri = state.roomAvatarUrl,
modifier = Modifier.fillMaxSize(),
)
}
}
if (state.canChangeAvatar) {
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.clip(CircleShape)
.background(LocalColors.current.gray1400)
.size(24.dp),
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier.size(16.dp),
imageVector = Icons.Outlined.AddAPhoto,
contentDescription = "",
tint = MaterialTheme.colorScheme.onPrimary,
)
}
}
}
}
}
@Composable
private fun LabelledReadOnlyField(
title: String,
value: String,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
modifier = Modifier.padding(horizontal = 16.dp),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
text = title,
)
Text(
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 12.dp),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.primary,
text = value,
)
}
}
private fun Modifier.clearFocusOnTap(focusManager: FocusManager): Modifier =
pointerInput(Unit) {
detectTapGestures(onTap = {
focusManager.clearFocus()
})
}
@Preview
@Composable
fun RoomDetailsEditViewLightPreview(@PreviewParameter(RoomDetailsEditStateProvider::class) state: RoomDetailsEditState) =
ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
fun RoomDetailsEditViewDarkPreview(@PreviewParameter(RoomDetailsEditStateProvider::class) state: RoomDetailsEditState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable
private fun ContentToPreview(state: RoomDetailsEditState) {
RoomDetailsEditView(
state = state,
onBackPressed = {},
onRoomEdited = {},
)
}

View File

@@ -12,7 +12,10 @@
<string name="screen_room_details_edition_error_title">"Unable to update room"</string>
<string name="screen_room_details_encryption_enabled_subtitle">"Messages are secured with locks. Only you and the recipients have the unique keys to unlock them."</string>
<string name="screen_room_details_encryption_enabled_title">"Message encryption enabled"</string>
<string name="screen_room_details_room_name_label">"Room name"</string>
<string name="screen_room_details_room_name_placeholder">"e.g. Product Sprint"</string>
<string name="screen_room_details_share_room_title">"Share room"</string>
<string name="screen_room_details_topic_placeholder">"What is this room about?"</string>
<string name="screen_room_details_updating_room">"Updating room…"</string>
<string name="screen_room_member_list_pending_header_title">"Pending"</string>
<string name="screen_room_member_list_room_members_header_title">"Room members"</string>
@@ -22,7 +25,7 @@
<string name="screen_dm_details_unblock_alert_action">"Unblock"</string>
<string name="screen_dm_details_unblock_alert_description">"On unblocking the user, you will be able to see all messages by them again."</string>
<string name="screen_dm_details_unblock_user">"Unblock user"</string>
<string name="screen_room_details_invite_people_title">"Invite people"</string>
<string name="screen_room_details_invite_people_title">"Invite friends to Element"</string>
<string name="screen_room_details_leave_room_title">"Leave room"</string>
<string name="screen_room_details_people_title">"People"</string>
<string name="screen_room_details_security_title">"Security"</string>

View File

@@ -19,10 +19,11 @@ package io.element.android.features.roomdetails
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth
import com.google.common.truth.Truth.assertThat
import io.element.android.features.leaveroom.fake.LeaveRoomPresenterFake
import io.element.android.features.roomdetails.impl.RoomDetailsPresenter
import io.element.android.features.roomdetails.impl.RoomDetailsType
import io.element.android.features.roomdetails.impl.RoomTopicState
import io.element.android.features.roomdetails.impl.members.aRoomMember
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
import io.element.android.libraries.architecture.Async
@@ -31,8 +32,8 @@ import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.RoomMembershipState
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
@@ -40,7 +41,6 @@ import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
@@ -48,9 +48,6 @@ import org.junit.Test
@ExperimentalCoroutinesApi
class RoomDetailsPresenterTests {
private val roomMembershipObserver = RoomMembershipObserver()
private val testCoroutineDispatchers = testCoroutineDispatchers()
private fun aRoomDetailsPresenter(room: MatrixRoom): RoomDetailsPresenter {
val roomMemberDetailsPresenterFactory = object : RoomMemberDetailsPresenter.Factory {
override fun create(roomMemberId: UserId): RoomMemberDetailsPresenter {
@@ -68,12 +65,12 @@ class RoomDetailsPresenterTests {
presenter.present()
}.test {
val initialState = awaitItem()
Truth.assertThat(initialState.roomId).isEqualTo(room.roomId.value)
Truth.assertThat(initialState.roomName).isEqualTo(room.name)
Truth.assertThat(initialState.roomAvatarUrl).isEqualTo(room.avatarUrl)
Truth.assertThat(initialState.roomTopic).isEqualTo(room.topic)
Truth.assertThat(initialState.memberCount).isEqualTo(Async.Uninitialized)
Truth.assertThat(initialState.isEncrypted).isEqualTo(room.isEncrypted)
assertThat(initialState.roomId).isEqualTo(room.roomId.value)
assertThat(initialState.roomName).isEqualTo(room.name)
assertThat(initialState.roomAvatarUrl).isEqualTo(room.avatarUrl)
assertThat(initialState.roomTopic).isEqualTo(RoomTopicState.ExistingTopic(room.topic!!))
assertThat(initialState.memberCount).isEqualTo(Async.Uninitialized)
assertThat(initialState.isEncrypted).isEqualTo(room.isEncrypted)
cancelAndIgnoreRemainingEvents()
}
@@ -93,22 +90,22 @@ class RoomDetailsPresenterTests {
}.test {
room.givenRoomMembersState(MatrixRoomMembersState.Unknown)
val initialState = awaitItem()
Truth.assertThat(initialState.memberCount).isEqualTo(Async.Uninitialized)
assertThat(initialState.memberCount).isEqualTo(Async.Uninitialized)
skipItems(1)
room.givenRoomMembersState(MatrixRoomMembersState.Pending(null))
val loadingState = awaitItem()
Truth.assertThat(loadingState.memberCount).isEqualTo(Async.Loading(null))
assertThat(loadingState.memberCount).isEqualTo(Async.Loading(null))
room.givenRoomMembersState(MatrixRoomMembersState.Error(error))
skipItems(1)
val failureState = awaitItem()
Truth.assertThat(failureState.memberCount).isEqualTo(Async.Failure(error, null))
assertThat(failureState.memberCount).isEqualTo(Async.Failure(error, null))
room.givenRoomMembersState(MatrixRoomMembersState.Ready(roomMembers))
skipItems(1)
val successState = awaitItem()
Truth.assertThat(successState.memberCount).isEqualTo(Async.Success(1))
assertThat(successState.memberCount).isEqualTo(Async.Success(1))
cancelAndIgnoreRemainingEvents()
}
@@ -122,7 +119,7 @@ class RoomDetailsPresenterTests {
presenter.present()
}.test {
val initialState = awaitItem()
Truth.assertThat(initialState.roomName).isEqualTo(room.displayName)
assertThat(initialState.roomName).isEqualTo(room.displayName)
cancelAndIgnoreRemainingEvents()
}
@@ -144,7 +141,7 @@ class RoomDetailsPresenterTests {
presenter.present()
}.test {
val initialState = awaitItem()
Truth.assertThat(initialState.roomType).isEqualTo(RoomDetailsType.Dm(otherRoomMember))
assertThat(initialState.roomType).isEqualTo(RoomDetailsType.Dm(otherRoomMember))
cancelAndIgnoreRemainingEvents()
}
@@ -160,9 +157,9 @@ class RoomDetailsPresenterTests {
presenter.present()
}.test {
// Initially false
Truth.assertThat(awaitItem().canInvite).isFalse()
assertThat(awaitItem().canInvite).isFalse()
// Then the asynchronous check completes and it becomes true
Truth.assertThat(awaitItem().canInvite).isTrue()
assertThat(awaitItem().canInvite).isTrue()
cancelAndIgnoreRemainingEvents()
}
@@ -177,7 +174,7 @@ class RoomDetailsPresenterTests {
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
Truth.assertThat(awaitItem().canInvite).isFalse()
assertThat(awaitItem().canInvite).isFalse()
}
}
@@ -190,7 +187,103 @@ class RoomDetailsPresenterTests {
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
Truth.assertThat(awaitItem().canInvite).isFalse()
assertThat(awaitItem().canInvite).isFalse()
}
}
@Test
fun `present - initial state when user can edit one attribute`() = runTest {
val room = aMatrixRoom().apply {
givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.success(true))
givenCanSendStateResult(StateEventType.ROOM_NAME, Result.success(false))
givenCanSendStateResult(StateEventType.ROOM_AVATAR, Result.failure(Throwable("Whelp")))
givenCanInviteResult(Result.success(false))
}
val presenter = aRoomDetailsPresenter(room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
// Initially false
assertThat(awaitItem().canEdit).isFalse()
// Then the asynchronous check completes and it becomes true
assertThat(awaitItem().canEdit).isTrue()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - initial state when user can edit all attributes`() = runTest {
val room = aMatrixRoom().apply {
givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.success(true))
givenCanSendStateResult(StateEventType.ROOM_NAME, Result.success(true))
givenCanSendStateResult(StateEventType.ROOM_AVATAR, Result.success(true))
givenCanInviteResult(Result.success(false))
}
val presenter = aRoomDetailsPresenter(room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
// Initially false
assertThat(awaitItem().canEdit).isFalse()
// Then the asynchronous check completes and it becomes true
assertThat(awaitItem().canEdit).isTrue()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - initial state when user can edit no attributes`() = runTest {
val room = aMatrixRoom().apply {
givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.success(false))
givenCanSendStateResult(StateEventType.ROOM_NAME, Result.success(false))
givenCanSendStateResult(StateEventType.ROOM_AVATAR, Result.success(false))
givenCanInviteResult(Result.success(false))
}
val presenter = aRoomDetailsPresenter(room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
// Initially false, and no further events
assertThat(awaitItem().canEdit).isFalse()
}
}
@Test
fun `present - topic state is hidden when no topic and user has no permission`() = runTest {
val room = aMatrixRoom(topic = null).apply {
givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.success(false))
givenCanInviteResult(Result.success(false))
}
val presenter = aRoomDetailsPresenter(room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
// The initial state is "hidden" and no further state changes happen
assertThat(awaitItem().roomTopic).isEqualTo(RoomTopicState.Hidden)
}
}
@Test
fun `present - topic state is 'can add topic' when no topic and user has permission`() = runTest {
val room = aMatrixRoom(topic = null).apply {
givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.success(true))
givenCanInviteResult(Result.success(false))
}
val presenter = aRoomDetailsPresenter(room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
// Ignore the initial state
skipItems(1)
// When the async permission check finishes, the topic state will be updated
assertThat(awaitItem().roomTopic).isEqualTo(RoomTopicState.CanAddTopic)
cancelAndIgnoreRemainingEvents()
}
}
}

View File

@@ -0,0 +1,618 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomdetails.edit
import android.net.Uri
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.features.roomdetails.aMatrixRoom
import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditEvents
import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditPresenter
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.api.ThumbnailProcessingInfo
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Test
import java.io.File
@ExperimentalCoroutinesApi
class RoomDetailsEditPresenterTest {
private lateinit var fakePickerProvider: FakePickerProvider
private lateinit var fakeMediaPreProcessor: FakeMediaPreProcessor
private val roomAvatarUri: Uri = mockk()
private val anotherAvatarUri: Uri = mockk()
private val fakeFileContents = ByteArray(2)
@Before
fun setup() {
fakePickerProvider = FakePickerProvider()
fakeMediaPreProcessor = FakeMediaPreProcessor()
mockkStatic(Uri::class)
every { Uri.parse(AN_AVATAR_URL) } returns roomAvatarUri
every { Uri.parse(ANOTHER_AVATAR_URL) } returns anotherAvatarUri
}
@After
fun tearDown() {
unmockkAll()
}
private fun aRoomDetailsEditPresenter(room: MatrixRoom): RoomDetailsEditPresenter {
return RoomDetailsEditPresenter(
room = room,
mediaPickerProvider = fakePickerProvider,
mediaPreProcessor = fakeMediaPreProcessor,
)
}
@Test
fun `present - initial state is created from room info`() = runTest {
val room = aMatrixRoom(avatarUrl = AN_AVATAR_URL)
val presenter = aRoomDetailsEditPresenter(room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.roomId).isEqualTo(room.roomId.value)
assertThat(initialState.roomName).isEqualTo(room.name)
assertThat(initialState.roomAvatarUrl).isEqualTo(roomAvatarUri)
assertThat(initialState.roomTopic).isEqualTo(room.topic.orEmpty())
assertThat(initialState.avatarActions).containsExactly(
AvatarAction.ChoosePhoto,
AvatarAction.TakePhoto,
AvatarAction.Remove
)
assertThat(initialState.saveButtonEnabled).isEqualTo(false)
assertThat(initialState.saveAction).isInstanceOf(Async.Uninitialized::class.java)
}
}
@Test
fun `present - sets canChangeName if user has permission`() = runTest {
val room = aMatrixRoom(avatarUrl = AN_AVATAR_URL).apply {
givenCanSendStateResult(StateEventType.ROOM_NAME, Result.success(true))
givenCanSendStateResult(StateEventType.ROOM_AVATAR, Result.success(false))
givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.failure(Throwable("Oops"))) }
val presenter = aRoomDetailsEditPresenter(room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
// Initially false
val initialState = awaitItem()
assertThat(initialState.canChangeName).isFalse()
assertThat(initialState.canChangeAvatar).isFalse()
assertThat(initialState.canChangeTopic).isFalse()
// When the asynchronous check completes, the single field we can edit is true
val settledState = awaitItem()
assertThat(settledState.canChangeName).isTrue()
assertThat(settledState.canChangeAvatar).isFalse()
assertThat(settledState.canChangeTopic).isFalse()
}
}
@Test
fun `present - sets canChangeAvatar if user has permission`() = runTest {
val room = aMatrixRoom(avatarUrl = AN_AVATAR_URL).apply {
givenCanSendStateResult(StateEventType.ROOM_NAME, Result.success(false))
givenCanSendStateResult(StateEventType.ROOM_AVATAR, Result.success(true))
givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.failure(Throwable("Oops")))
}
val presenter = aRoomDetailsEditPresenter(room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
// Initially false
val initialState = awaitItem()
assertThat(initialState.canChangeName).isFalse()
assertThat(initialState.canChangeAvatar).isFalse()
assertThat(initialState.canChangeTopic).isFalse()
// When the asynchronous check completes, the single field we can edit is true
val settledState = awaitItem()
assertThat(settledState.canChangeName).isFalse()
assertThat(settledState.canChangeAvatar).isTrue()
assertThat(settledState.canChangeTopic).isFalse()
}
}
@Test
fun `present - sets canChangeTopic if user has permission`() = runTest {
val room = aMatrixRoom(avatarUrl = AN_AVATAR_URL).apply {
givenCanSendStateResult(StateEventType.ROOM_NAME, Result.success(false))
givenCanSendStateResult(StateEventType.ROOM_AVATAR, Result.failure(Throwable("Oops")))
givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.success(true))
}
val presenter = aRoomDetailsEditPresenter(room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
// Initially false
val initialState = awaitItem()
assertThat(initialState.canChangeName).isFalse()
assertThat(initialState.canChangeAvatar).isFalse()
assertThat(initialState.canChangeTopic).isFalse()
// When the asynchronous check completes, the single field we can edit is true
val settledState = awaitItem()
assertThat(settledState.canChangeName).isFalse()
assertThat(settledState.canChangeAvatar).isFalse()
assertThat(settledState.canChangeTopic).isTrue()
}
}
@Test
fun `present - updates state in response to changes`() = runTest {
val room = aMatrixRoom(topic = "My topic", name = "Name", avatarUrl = AN_AVATAR_URL)
val presenter = aRoomDetailsEditPresenter(room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.roomTopic).isEqualTo("My topic")
assertThat(initialState.roomName).isEqualTo("Name")
assertThat(initialState.roomAvatarUrl).isEqualTo(roomAvatarUri)
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name II"))
awaitItem().apply {
assertThat(roomTopic).isEqualTo("My topic")
assertThat(roomName).isEqualTo("Name II")
assertThat(roomAvatarUrl).isEqualTo(roomAvatarUri)
}
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name III"))
awaitItem().apply {
assertThat(roomTopic).isEqualTo("My topic")
assertThat(roomName).isEqualTo("Name III")
assertThat(roomAvatarUrl).isEqualTo(roomAvatarUri)
}
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("Another topic"))
awaitItem().apply {
assertThat(roomTopic).isEqualTo("Another topic")
assertThat(roomName).isEqualTo("Name III")
assertThat(roomAvatarUrl).isEqualTo(roomAvatarUri)
}
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove))
awaitItem().apply {
assertThat(roomTopic).isEqualTo("Another topic")
assertThat(roomName).isEqualTo("Name III")
assertThat(roomAvatarUrl).isNull()
}
}
}
@Test
fun `present - obtains avatar uris from gallery`() = runTest {
val room = aMatrixRoom(topic = "My topic", name = "Name", avatarUrl = AN_AVATAR_URL)
fakePickerProvider.givenResult(anotherAvatarUri)
val presenter = aRoomDetailsEditPresenter(room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.roomAvatarUrl).isEqualTo(roomAvatarUri)
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
awaitItem().apply {
assertThat(roomAvatarUrl).isEqualTo(anotherAvatarUri)
}
}
}
@Test
fun `present - obtains avatar uris from camera`() = runTest {
val room = aMatrixRoom(topic = "My topic", name = "Name", avatarUrl = AN_AVATAR_URL)
fakePickerProvider.givenResult(anotherAvatarUri)
val presenter = aRoomDetailsEditPresenter(room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.roomAvatarUrl).isEqualTo(roomAvatarUri)
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.TakePhoto))
awaitItem().apply {
assertThat(roomAvatarUrl).isEqualTo(anotherAvatarUri)
}
}
}
@Test
fun `present - updates save button state`() = runTest {
val room = aMatrixRoom(topic = "My topic", name = "Name", avatarUrl = AN_AVATAR_URL)
fakePickerProvider.givenResult(roomAvatarUri)
val presenter = aRoomDetailsEditPresenter(room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.saveButtonEnabled).isEqualTo(false)
// Once a change is made, the save button is enabled
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name II"))
awaitItem().apply {
assertThat(saveButtonEnabled).isEqualTo(true)
}
// If it's reverted then the save disables again
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name"))
awaitItem().apply {
assertThat(saveButtonEnabled).isEqualTo(false)
}
// Make a change...
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("Another topic"))
awaitItem().apply {
assertThat(saveButtonEnabled).isEqualTo(true)
}
// Revert it...
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("My topic"))
awaitItem().apply {
assertThat(saveButtonEnabled).isEqualTo(false)
}
// Make a change...
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove))
awaitItem().apply {
assertThat(saveButtonEnabled).isEqualTo(true)
}
// Revert it...
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
awaitItem().apply {
assertThat(saveButtonEnabled).isEqualTo(false)
}
}
}
@Test
fun `present - updates save button state when initial values are null`() = runTest {
val room = aMatrixRoom(topic = null, name = null, displayName = "fallback", avatarUrl = null)
fakePickerProvider.givenResult(roomAvatarUri)
val presenter = aRoomDetailsEditPresenter(room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.saveButtonEnabled).isEqualTo(false)
// Once a change is made, the save button is enabled
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name II"))
awaitItem().apply {
assertThat(saveButtonEnabled).isEqualTo(true)
}
// If it's reverted then the save disables again
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("fallback"))
awaitItem().apply {
assertThat(saveButtonEnabled).isEqualTo(false)
}
// Make a change...
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("Another topic"))
awaitItem().apply {
assertThat(saveButtonEnabled).isEqualTo(true)
}
// Revert it...
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic(""))
awaitItem().apply {
assertThat(saveButtonEnabled).isEqualTo(false)
}
// Make a change...
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
awaitItem().apply {
assertThat(saveButtonEnabled).isEqualTo(true)
}
// Revert it...
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove))
awaitItem().apply {
assertThat(saveButtonEnabled).isEqualTo(false)
}
}
}
@Test
fun `present - save changes room details if different`() = runTest {
val room = aMatrixRoom(topic = "My topic", name = "Name", avatarUrl = AN_AVATAR_URL)
val presenter = aRoomDetailsEditPresenter(room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("New name"))
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("New topic"))
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove))
initialState.eventSink(RoomDetailsEditEvents.Save)
assertThat(room.newName).isEqualTo("New name")
assertThat(room.newTopic).isEqualTo("New topic")
assertThat(room.newAvatarData).isNull()
assertThat(room.removedAvatar).isTrue()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - save doesn't change room details if they're the same trimmed`() = runTest {
val room = aMatrixRoom(topic = "My topic", name = "Name", avatarUrl = AN_AVATAR_URL)
val presenter = aRoomDetailsEditPresenter(room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName(" Name "))
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic(" My topic "))
initialState.eventSink(RoomDetailsEditEvents.Save)
assertThat(room.newName).isNull()
assertThat(room.newTopic).isNull()
assertThat(room.newAvatarData).isNull()
assertThat(room.removedAvatar).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - save doesn't change topic if it was unset and is now blank`() = runTest {
val room = aMatrixRoom(topic = null, name = "Name", avatarUrl = AN_AVATAR_URL)
val presenter = aRoomDetailsEditPresenter(room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic(""))
initialState.eventSink(RoomDetailsEditEvents.Save)
assertThat(room.newName).isNull()
assertThat(room.newTopic).isNull()
assertThat(room.newAvatarData).isNull()
assertThat(room.removedAvatar).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - save doesn't change name if it's now empty`() = runTest {
val room = aMatrixRoom(topic = "My topic", name = "Name", avatarUrl = AN_AVATAR_URL)
val presenter = aRoomDetailsEditPresenter(room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName(""))
initialState.eventSink(RoomDetailsEditEvents.Save)
assertThat(room.newName).isNull()
assertThat(room.newTopic).isNull()
assertThat(room.newAvatarData).isNull()
assertThat(room.removedAvatar).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - save processes and sets avatar when processor returns successfully`() = runTest {
val room = aMatrixRoom(topic = "My topic", name = "Name", avatarUrl = AN_AVATAR_URL)
givenPickerReturnsFile()
val presenter = aRoomDetailsEditPresenter(room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
initialState.eventSink(RoomDetailsEditEvents.Save)
skipItems(2)
assertThat(room.newName).isNull()
assertThat(room.newTopic).isNull()
assertThat(room.newAvatarData).isSameInstanceAs(fakeFileContents)
assertThat(room.removedAvatar).isFalse()
}
}
@Test
fun `present - save does not set avatar data if processor fails`() = runTest {
val room = aMatrixRoom(topic = "My topic", name = "Name", avatarUrl = AN_AVATAR_URL)
fakePickerProvider.givenResult(anotherAvatarUri)
fakeMediaPreProcessor.givenResult(Result.failure(Throwable("Oh no")))
val presenter = aRoomDetailsEditPresenter(room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
initialState.eventSink(RoomDetailsEditEvents.Save)
skipItems(1)
assertThat(room.newName).isNull()
assertThat(room.newTopic).isNull()
assertThat(room.newAvatarData).isNull()
assertThat(room.removedAvatar).isFalse()
assertThat(awaitItem().saveAction).isInstanceOf(Async.Failure::class.java)
}
}
@Test
fun `present - sets save action to failure if name update fails`() = runTest {
val room = aMatrixRoom(topic = "My topic", name = "Name", avatarUrl = AN_AVATAR_URL).apply {
givenSetNameResult(Result.failure(Throwable("!")))
}
saveAndAssertFailure(room, RoomDetailsEditEvents.UpdateRoomName("New name"))
}
@Test
fun `present - sets save action to failure if topic update fails`() = runTest {
val room = aMatrixRoom(topic = "My topic", name = "Name", avatarUrl = AN_AVATAR_URL).apply {
givenSetTopicResult(Result.failure(Throwable("!")))
}
saveAndAssertFailure(room, RoomDetailsEditEvents.UpdateRoomTopic("New topic"))
}
@Test
fun `present - sets save action to failure if removing avatar fails`() = runTest {
val room = aMatrixRoom(topic = "My topic", name = "Name", avatarUrl = AN_AVATAR_URL).apply {
givenRemoveAvatarResult(Result.failure(Throwable("!")))
}
saveAndAssertFailure(room, RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove))
}
@Test
fun `present - sets save action to failure if setting avatar fails`() = runTest {
givenPickerReturnsFile()
val room = aMatrixRoom(topic = "My topic", name = "Name", avatarUrl = AN_AVATAR_URL).apply {
givenUpdateAvatarResult(Result.failure(Throwable("!")))
}
saveAndAssertFailure(room, RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
}
@Test
fun `present - CancelSaveChanges resets save action state`() = runTest {
givenPickerReturnsFile()
val room = aMatrixRoom(topic = "My topic", name = "Name", avatarUrl = AN_AVATAR_URL).apply {
givenSetTopicResult(Result.failure(Throwable("!")))
}
val presenter = aRoomDetailsEditPresenter(room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("foo"))
initialState.eventSink(RoomDetailsEditEvents.Save)
skipItems(1)
assertThat(awaitItem().saveAction).isInstanceOf(Async.Failure::class.java)
initialState.eventSink(RoomDetailsEditEvents.CancelSaveChanges)
assertThat(awaitItem().saveAction).isInstanceOf(Async.Uninitialized::class.java)
}
}
private suspend fun saveAndAssertFailure(room: MatrixRoom, event: RoomDetailsEditEvents) {
val presenter = aRoomDetailsEditPresenter(room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(event)
initialState.eventSink(RoomDetailsEditEvents.Save)
skipItems(1)
assertThat(awaitItem().saveAction).isInstanceOf(Async.Failure::class.java)
}
}
private fun givenPickerReturnsFile() {
mockkStatic(File::readBytes)
val processedFile: File = mockk {
every { readBytes() } returns fakeFileContents
}
fakePickerProvider.givenResult(anotherAvatarUri)
fakeMediaPreProcessor.givenResult(Result.success(MediaUploadInfo.Image(
file = processedFile,
info = mockk(),
thumbnailInfo = ThumbnailProcessingInfo(
file = processedFile,
info = mockk(),
blurhash = "",
)
)))
}
companion object {
private const val ANOTHER_AVATAR_URL = "example://camera/foo.jpg"
}
}

View File

@@ -79,3 +79,6 @@ val Compound_Gray_300_Light = Color(0xFFF0F2F5)
val Compound_Gray_300_Dark = Color(0xFF1D1F24)
val Compound_Gray_400_Light = Color(0xFFE1E6EC)
val Compound_Gray_400_Dark = Color(0xFF26282D)
val Gray_1400_Light = Color(0xFF1B1D22)
val Gray_1400_Dark = Color(0xFFEBEEF2)

View File

@@ -14,18 +14,17 @@
* limitations under the License.
*/
package io.element.android.features.createroom.impl.components
package io.element.android.libraries.designsystem.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.features.createroom.impl.R
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Text
@@ -36,8 +35,9 @@ fun LabelledTextField(
label: String,
value: String,
modifier: Modifier = Modifier,
placeholder: String = "",
maxLines: Int = 1,
placeholder: String? = null,
maxLines: Int = Int.MAX_VALUE,
singleLine: Boolean = false,
onValueChange: (String) -> Unit = {},
) {
Column(
@@ -46,15 +46,17 @@ fun LabelledTextField(
) {
Text(
modifier = Modifier.padding(horizontal = 16.dp),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
text = label
)
TextField(
modifier = Modifier.fillMaxWidth(),
value = value,
placeholder = { Text(placeholder) },
placeholder = placeholder?.let { { Text(placeholder) } },
onValueChange = onValueChange,
singleLine = maxLines == 1,
singleLine = singleLine,
maxLines = maxLines,
)
}
@@ -72,14 +74,14 @@ fun LabelledTextFieldDarkPreview() = ElementPreviewDark { ContentToPreview() }
private fun ContentToPreview() {
Column {
LabelledTextField(
label = stringResource(R.string.screen_create_room_room_name_label),
label = "Room name",
value = "",
placeholder = stringResource(R.string.screen_create_room_room_name_placeholder),
placeholder = "e.g. Product Sprint",
)
LabelledTextField(
label = stringResource(R.string.screen_create_room_room_name_label),
label = "Room name",
value = "a room name",
placeholder = stringResource(R.string.screen_create_room_room_name_placeholder),
placeholder = "e.g. Product Sprint",
)
}
}

View File

@@ -25,6 +25,7 @@ import io.element.android.libraries.designsystem.Black_800
import io.element.android.libraries.designsystem.Black_950
import io.element.android.libraries.designsystem.Compound_Gray_300_Dark
import io.element.android.libraries.designsystem.DarkGrey
import io.element.android.libraries.designsystem.Gray_1400_Dark
import io.element.android.libraries.designsystem.Gray_300
import io.element.android.libraries.designsystem.Gray_400
import io.element.android.libraries.designsystem.Compound_Gray_400_Dark
@@ -42,6 +43,7 @@ fun elementColorsDark() = ElementColors(
quinary = Gray_450,
gray300 = Compound_Gray_300_Dark,
gray400 = Compound_Gray_400_Dark,
gray1400 = Gray_1400_Dark,
textActionCritical = TextColorCriticalDark,
isLight = false,
)

View File

@@ -22,12 +22,13 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import io.element.android.libraries.designsystem.Azure
import io.element.android.libraries.designsystem.Black_900
import io.element.android.libraries.designsystem.Compound_Gray_300_Light
import io.element.android.libraries.designsystem.Compound_Gray_400_Light
import io.element.android.libraries.designsystem.Gray_100
import io.element.android.libraries.designsystem.Gray_1400_Light
import io.element.android.libraries.designsystem.Gray_150
import io.element.android.libraries.designsystem.Gray_200
import io.element.android.libraries.designsystem.Gray_25
import io.element.android.libraries.designsystem.Compound_Gray_300_Light
import io.element.android.libraries.designsystem.Compound_Gray_400_Light
import io.element.android.libraries.designsystem.Gray_50
import io.element.android.libraries.designsystem.SystemGrey5Light
import io.element.android.libraries.designsystem.SystemGrey6Light
@@ -42,6 +43,7 @@ fun elementColorsLight() = ElementColors(
quinary = Gray_50,
gray300 = Compound_Gray_300_Light,
gray400 = Compound_Gray_400_Light,
gray1400 = Gray_1400_Light,
textActionCritical = TextColorCriticalLight,
isLight = true,
)

View File

@@ -31,6 +31,7 @@ class ElementColors(
quinary: Color,
gray300: Color,
gray400: Color,
gray1400: Color,
textActionCritical: Color,
isLight: Boolean
) {
@@ -53,6 +54,9 @@ class ElementColors(
var gray400 by mutableStateOf(gray400)
private set
var gray1400 by mutableStateOf(gray1400)
private set
var textActionCritical by mutableStateOf(textActionCritical)
private set
@@ -67,6 +71,7 @@ class ElementColors(
quinary: Color = this.quinary,
gray300: Color = this.gray300,
gray400: Color = this.gray400,
gray1400: Color = this.gray1400,
textActionCritical: Color = this.textActionCritical,
isLight: Boolean = this.isLight,
) = ElementColors(
@@ -77,6 +82,7 @@ class ElementColors(
quinary = quinary,
gray300 = gray300,
gray400 = gray400,
gray1400 = gray1400,
textActionCritical = textActionCritical,
isLight = isLight,
)
@@ -89,6 +95,7 @@ class ElementColors(
quinary = other.quinary
gray300 = other.gray300
gray400 = other.gray400
gray1400 = other.gray1400
textActionCritical = other.textActionCritical
isLight = other.isLight
}

View File

@@ -89,4 +89,14 @@ interface MatrixRoom : Closeable {
suspend fun inviteUserById(id: UserId): Result<Unit>
suspend fun canInvite(): Result<Boolean>
suspend fun canSendStateEvent(type: StateEventType): Result<Boolean>
suspend fun updateAvatar(mimeType: String, data: ByteArray): Result<Unit>
suspend fun removeAvatar(): Result<Unit>
suspend fun setName(name: String): Result<Unit>
suspend fun setTopic(topic: String): Result<Unit>
}

View File

@@ -0,0 +1,41 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.api.room
enum class StateEventType {
POLICY_RULE_ROOM,
POLICY_RULE_SERVER,
POLICY_RULE_USER,
ROOM_ALIASES,
ROOM_AVATAR,
ROOM_CANONICAL_ALIAS,
ROOM_CREATE,
ROOM_ENCRYPTION,
ROOM_GUEST_ACCESS,
ROOM_HISTORY_VISIBILITY,
ROOM_JOIN_RULES,
ROOM_MEMBER_EVENT,
ROOM_NAME,
ROOM_PINNED_EVENTS,
ROOM_POWER_LEVELS,
ROOM_SERVER_ACL,
ROOM_THIRD_PARTY_INVITE,
ROOM_TOMBSTONE,
ROOM_TOPIC,
SPACE_CHILD,
SPACE_PARENT;
}

View File

@@ -27,12 +27,14 @@ import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.VideoInfo
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.StateEventType
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.impl.media.map
import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -224,6 +226,12 @@ class RustMatrixRoom(
}
}
override suspend fun canSendStateEvent(type: StateEventType): Result<Boolean> = withContext(coroutineDispatchers.io) {
runCatching {
innerRoom.member(sessionId.value).use { it.canSendState(type.map()) }
}
}
override suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo): Result<Unit> = withContext(coroutineDispatchers.io) {
runCatching {
innerRoom.sendImage(file.path, thumbnailFile.path, imageInfo.map())
@@ -247,4 +255,33 @@ class RustMatrixRoom(
innerRoom.sendFile(file.path, fileInfo.map())
}
}
@OptIn(ExperimentalUnsignedTypes::class)
override suspend fun updateAvatar(mimeType: String, data: ByteArray): Result<Unit> =
withContext(Dispatchers.IO) {
runCatching {
innerRoom.uploadAvatar(mimeType, data.toUByteArray().toList())
}
}
override suspend fun removeAvatar(): Result<Unit> =
withContext(Dispatchers.IO) {
runCatching {
innerRoom.removeAvatar()
}
}
override suspend fun setName(name: String): Result<Unit> =
withContext(Dispatchers.IO) {
runCatching {
innerRoom.setName(name)
}
}
override suspend fun setTopic(topic: String): Result<Unit> =
withContext(Dispatchers.IO) {
runCatching {
innerRoom.setTopic(topic)
}
}
}

View File

@@ -0,0 +1,68 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.impl.room
import io.element.android.libraries.matrix.api.room.StateEventType
import org.matrix.rustcomponents.sdk.StateEventType as RustStateEventType
fun StateEventType.map(): RustStateEventType = when (this) {
StateEventType.POLICY_RULE_ROOM -> RustStateEventType.POLICY_RULE_ROOM
StateEventType.POLICY_RULE_SERVER -> RustStateEventType.POLICY_RULE_SERVER
StateEventType.POLICY_RULE_USER -> RustStateEventType.POLICY_RULE_USER
StateEventType.ROOM_ALIASES -> RustStateEventType.ROOM_ALIASES
StateEventType.ROOM_AVATAR -> RustStateEventType.ROOM_AVATAR
StateEventType.ROOM_CANONICAL_ALIAS -> RustStateEventType.ROOM_CANONICAL_ALIAS
StateEventType.ROOM_CREATE -> RustStateEventType.ROOM_CREATE
StateEventType.ROOM_ENCRYPTION -> RustStateEventType.ROOM_ENCRYPTION
StateEventType.ROOM_GUEST_ACCESS -> RustStateEventType.ROOM_GUEST_ACCESS
StateEventType.ROOM_HISTORY_VISIBILITY -> RustStateEventType.ROOM_HISTORY_VISIBILITY
StateEventType.ROOM_JOIN_RULES -> RustStateEventType.ROOM_JOIN_RULES
StateEventType.ROOM_MEMBER_EVENT -> RustStateEventType.ROOM_MEMBER_EVENT
StateEventType.ROOM_NAME -> RustStateEventType.ROOM_NAME
StateEventType.ROOM_PINNED_EVENTS -> RustStateEventType.ROOM_PINNED_EVENTS
StateEventType.ROOM_POWER_LEVELS -> RustStateEventType.ROOM_POWER_LEVELS
StateEventType.ROOM_SERVER_ACL -> RustStateEventType.ROOM_SERVER_ACL
StateEventType.ROOM_THIRD_PARTY_INVITE -> RustStateEventType.ROOM_THIRD_PARTY_INVITE
StateEventType.ROOM_TOMBSTONE -> RustStateEventType.ROOM_TOMBSTONE
StateEventType.ROOM_TOPIC -> RustStateEventType.ROOM_TOPIC
StateEventType.SPACE_CHILD -> RustStateEventType.SPACE_CHILD
StateEventType.SPACE_PARENT -> RustStateEventType.SPACE_PARENT
}
fun RustStateEventType.map(): StateEventType = when (this) {
RustStateEventType.POLICY_RULE_ROOM -> StateEventType.POLICY_RULE_ROOM
RustStateEventType.POLICY_RULE_SERVER -> StateEventType.POLICY_RULE_SERVER
RustStateEventType.POLICY_RULE_USER -> StateEventType.POLICY_RULE_USER
RustStateEventType.ROOM_ALIASES -> StateEventType.ROOM_ALIASES
RustStateEventType.ROOM_AVATAR -> StateEventType.ROOM_AVATAR
RustStateEventType.ROOM_CANONICAL_ALIAS -> StateEventType.ROOM_CANONICAL_ALIAS
RustStateEventType.ROOM_CREATE -> StateEventType.ROOM_CREATE
RustStateEventType.ROOM_ENCRYPTION -> StateEventType.ROOM_ENCRYPTION
RustStateEventType.ROOM_GUEST_ACCESS -> StateEventType.ROOM_GUEST_ACCESS
RustStateEventType.ROOM_HISTORY_VISIBILITY -> StateEventType.ROOM_HISTORY_VISIBILITY
RustStateEventType.ROOM_JOIN_RULES -> StateEventType.ROOM_JOIN_RULES
RustStateEventType.ROOM_MEMBER_EVENT -> StateEventType.ROOM_MEMBER_EVENT
RustStateEventType.ROOM_NAME -> StateEventType.ROOM_NAME
RustStateEventType.ROOM_PINNED_EVENTS -> StateEventType.ROOM_PINNED_EVENTS
RustStateEventType.ROOM_POWER_LEVELS -> StateEventType.ROOM_POWER_LEVELS
RustStateEventType.ROOM_SERVER_ACL -> StateEventType.ROOM_SERVER_ACL
RustStateEventType.ROOM_THIRD_PARTY_INVITE -> StateEventType.ROOM_THIRD_PARTY_INVITE
RustStateEventType.ROOM_TOMBSTONE -> StateEventType.ROOM_TOMBSTONE
RustStateEventType.ROOM_TOPIC -> StateEventType.ROOM_TOPIC
RustStateEventType.SPACE_CHILD -> StateEventType.SPACE_CHILD
RustStateEventType.SPACE_PARENT -> StateEventType.SPACE_PARENT
}

View File

@@ -26,6 +26,7 @@ import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.VideoInfo
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.StateEventType
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
@@ -62,7 +63,13 @@ class FakeMatrixRoom(
private var rejectInviteResult = Result.success(Unit)
private var inviteUserResult = Result.success(Unit)
private var canInviteResult = Result.success(true)
private val canSendStateResults = mutableMapOf<StateEventType, Result<Boolean>>()
private var sendMediaResult = Result.success(Unit)
private var setNameResult = Result.success(Unit)
private var setTopicResult = Result.success(Unit)
private var updateAvatarResult = Result.success(Unit)
private var removeAvatarResult = Result.success(Unit)
var sendMediaCount = 0
private set
@@ -75,6 +82,18 @@ class FakeMatrixRoom(
var invitedUserId: UserId? = null
private set
var newTopic: String? = null
private set
var newName: String? = null
private set
var newAvatarData: ByteArray? = null
private set
var removedAvatar: Boolean = false
private set
private var leaveRoomError: Throwable? = null
override val membersStateFlow: MutableStateFlow<MatrixRoomMembersState> = MutableStateFlow(MatrixRoomMembersState.Unknown)
@@ -151,6 +170,10 @@ class FakeMatrixRoom(
return canInviteResult
}
override suspend fun canSendStateEvent(type: StateEventType): Result<Boolean> {
return canSendStateResults[type] ?: Result.failure(IllegalStateException("No fake answer"))
}
override suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo): Result<Unit> = fakeSendMedia()
override suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo): Result<Unit> = fakeSendMedia()
@@ -166,6 +189,26 @@ class FakeMatrixRoom(
}
}
override suspend fun updateAvatar(mimeType: String, data: ByteArray): Result<Unit> {
newAvatarData = data
return updateAvatarResult
}
override suspend fun removeAvatar(): Result<Unit> {
removedAvatar = true
return removeAvatarResult
}
override suspend fun setName(name: String): Result<Unit> {
newName = name
return setNameResult
}
override suspend fun setTopic(topic: String): Result<Unit> {
newTopic = topic
return setTopicResult
}
override fun close() = Unit
fun givenLeaveRoomError(throwable: Throwable?) {
@@ -204,6 +247,10 @@ class FakeMatrixRoom(
canInviteResult = result
}
fun givenCanSendStateResult(type: StateEventType, result: Result<Boolean>) {
canSendStateResults[type] = result
}
fun givenIgnoreResult(result: Result<Unit>) {
ignoreResult = result
}
@@ -215,4 +262,20 @@ class FakeMatrixRoom(
fun givenSendMediaResult(result: Result<Unit>) {
sendMediaResult = result
}
fun givenUpdateAvatarResult(result: Result<Unit>) {
updateAvatarResult = result
}
fun givenRemoveAvatarResult(result: Result<Unit>) {
removeAvatarResult = result
}
fun givenSetNameResult(result: Result<Unit>) {
setNameResult = result
}
fun givenSetTopicResult(result: Result<Unit>) {
setTopicResult = result
}
}

View File

@@ -16,7 +16,7 @@
@file:OptIn(ExperimentalMaterialApi::class)
package io.element.android.features.createroom.impl.configureroom.avatar
package io.element.android.libraries.matrix.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxWidth
@@ -39,12 +39,13 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheetLayout
import io.element.android.libraries.matrix.ui.media.AvatarAction
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.launch
@Composable
fun AvatarActionListView(
fun AvatarActionBottomSheet(
actions: ImmutableList<AvatarAction>,
modalBottomSheetState: ModalBottomSheetState,
modifier: Modifier = Modifier,
@@ -62,7 +63,7 @@ fun AvatarActionListView(
modifier = modifier,
sheetState = modalBottomSheetState,
sheetContent = {
SheetContent(
AvatarActionBottomSheetContent(
actions = actions,
onActionClicked = ::onItemActionClicked,
modifier = Modifier
@@ -74,7 +75,7 @@ fun AvatarActionListView(
}
@Composable
private fun SheetContent(
private fun AvatarActionBottomSheetContent(
actions: ImmutableList<AvatarAction>,
modifier: Modifier = Modifier,
onActionClicked: (AvatarAction) -> Unit = { },
@@ -107,17 +108,17 @@ private fun SheetContent(
@Preview
@Composable
fun SheetContentLightPreview() =
fun AvatarActionBottomSheetLightPreview() =
ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
fun SheetContentDarkPreview() =
fun AvatarActionBottomSheetDarkPreview() =
ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
AvatarActionListView(
AvatarActionBottomSheet(
actions = persistentListOf(AvatarAction.TakePhoto, AvatarAction.ChoosePhoto, AvatarAction.Remove),
modalBottomSheetState = ModalBottomSheetState(
initialValue = ModalBottomSheetValue.Expanded

View File

@@ -14,11 +14,10 @@
* limitations under the License.
*/
package io.element.android.features.createroom.impl.components
package io.element.android.libraries.matrix.ui.components
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
@@ -43,16 +42,19 @@ import io.element.android.libraries.designsystem.preview.debugPlaceholderBackgro
import io.element.android.libraries.designsystem.theme.LocalColors
import io.element.android.libraries.designsystem.theme.components.Icon
/**
* An avatar that the user has selected, but which has not yet been uploaded to Matrix.
*
* The image is loaded from a local resource instead of from a MXC URI.
*/
@Composable
fun Avatar(
fun UnsavedAvatar(
avatarUri: Uri?,
modifier: Modifier = Modifier,
onClick: () -> Unit = {},
) {
val commonModifier = modifier
.size(70.dp)
.clip(CircleShape)
.clickable(onClick = onClick)
if (avatarUri != null) {
val context = LocalContext.current
@@ -82,16 +84,16 @@ fun Avatar(
@Preview
@Composable
fun AvatarLightPreview() = ElementPreviewLight { ContentToPreview() }
fun UnsavedAvatarLightPreview() = ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
fun AvatarDarkPreview() = ElementPreviewDark { ContentToPreview() }
fun UnsavedAvatarDarkPreview() = ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
Row {
Avatar(null)
Avatar(Uri.EMPTY)
UnsavedAvatar(null)
UnsavedAvatar(Uri.EMPTY)
}
}

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.createroom.impl.configureroom.avatar
package io.element.android.libraries.matrix.ui.media
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons