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.androidutils)
implementation(projects.libraries.mediapickers.api) implementation(projects.libraries.mediapickers.api)
implementation(projects.libraries.mediaupload.api) implementation(projects.libraries.mediaupload.api)
implementation(libs.coil.compose)
implementation(projects.libraries.usersearch.impl) implementation(projects.libraries.usersearch.impl)
implementation(libs.coil.compose)
api(projects.features.createroom.api) api(projects.features.createroom.api)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)

View file

@ -17,8 +17,8 @@
package io.element.android.features.createroom.impl.configureroom package io.element.android.features.createroom.impl.configureroom
import io.element.android.features.createroom.impl.CreateRoomConfig import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.features.createroom.impl.configureroom.avatar.AvatarAction
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.media.AvatarAction
sealed interface ConfigureRoomEvents { sealed interface ConfigureRoomEvents {
data class RoomNameChanged(val name: String) : 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 androidx.compose.runtime.rememberCoroutineScope
import io.element.android.features.createroom.impl.CreateRoomConfig import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.features.createroom.impl.CreateRoomDataStore import io.element.android.features.createroom.impl.CreateRoomDataStore
import io.element.android.features.createroom.impl.configureroom.avatar.AvatarAction
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.execute import io.element.android.libraries.architecture.execute
@ -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.CreateRoomParameters
import io.element.android.libraries.matrix.api.createroom.RoomPreset import io.element.android.libraries.matrix.api.createroom.RoomPreset
import io.element.android.libraries.matrix.api.createroom.RoomVisibility import io.element.android.libraries.matrix.api.createroom.RoomVisibility
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList

View file

@ -16,8 +16,8 @@
package io.element.android.features.createroom.impl.configureroom 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.CreateRoomConfig
import io.element.android.features.createroom.impl.configureroom.avatar.AvatarAction
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList

View file

@ -17,6 +17,7 @@
package io.element.android.features.createroom.impl.configureroom package io.element.android.features.createroom.impl.configureroom
import android.net.Uri import android.net.Uri
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column 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.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding 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.selection.selectableGroup
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ModalBottomSheetValue import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.rememberModalBottomSheetState 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import io.element.android.features.createroom.impl.R import io.element.android.features.createroom.impl.R
import io.element.android.features.createroom.impl.components.Avatar
import io.element.android.features.createroom.impl.components.LabelledTextField
import io.element.android.features.createroom.impl.components.RoomPrivacyOption import io.element.android.features.createroom.impl.components.RoomPrivacyOption
import io.element.android.features.createroom.impl.configureroom.avatar.AvatarActionListView
import io.element.android.libraries.architecture.Async 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.ProgressDialog
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog 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.Text
import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import 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.SelectedUsersList
import io.element.android.libraries.matrix.ui.components.UnsavedAvatar
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import io.element.android.libraries.ui.strings.R as StringR import io.element.android.libraries.ui.strings.R as StringR
@ -105,54 +108,48 @@ fun ConfigureRoomView(
) )
} }
) { padding -> ) { padding ->
LazyColumn( Column(
modifier = Modifier modifier = Modifier
.padding(padding) .padding(padding)
.imePadding()
.verticalScroll(rememberScrollState())
.consumeWindowInsets(padding), .consumeWindowInsets(padding),
verticalArrangement = Arrangement.spacedBy(24.dp), verticalArrangement = Arrangement.spacedBy(24.dp),
) { ) {
item { RoomNameWithAvatar(
RoomNameWithAvatar( modifier = Modifier.padding(horizontal = 16.dp),
modifier = Modifier.padding(horizontal = 16.dp), avatarUri = state.config.avatarUri,
avatarUri = state.config.avatarUri, roomName = state.config.roomName.orEmpty(),
roomName = state.config.roomName.orEmpty(), onAvatarClick = ::onAvatarClicked,
onAvatarClick = ::onAvatarClicked, onRoomNameChanged = { state.eventSink(ConfigureRoomEvents.RoomNameChanged(it)) },
onRoomNameChanged = { state.eventSink(ConfigureRoomEvents.RoomNameChanged(it)) }, )
) RoomTopic(
} modifier = Modifier.padding(horizontal = 16.dp),
item { topic = state.config.topic.orEmpty(),
RoomTopic( onTopicChanged = { state.eventSink(ConfigureRoomEvents.TopicChanged(it)) },
modifier = Modifier.padding(horizontal = 16.dp), )
topic = state.config.topic.orEmpty(),
onTopicChanged = { state.eventSink(ConfigureRoomEvents.TopicChanged(it)) },
)
}
if (state.config.invites.isNotEmpty()) { if (state.config.invites.isNotEmpty()) {
item { SelectedUsersList(
SelectedUsersList( contentPadding = PaddingValues(horizontal = 24.dp),
contentPadding = PaddingValues(horizontal = 24.dp), selectedUsers = state.config.invites,
selectedUsers = state.config.invites, onUserRemoved = {
onUserRemoved = {
focusManager.clearFocus()
state.eventSink(ConfigureRoomEvents.RemoveFromSelection(it))
},
)
}
}
item {
RoomPrivacyOptions(
modifier = Modifier.padding(bottom = 40.dp),
selected = state.config.privacy,
onOptionSelected = {
focusManager.clearFocus() 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, actions = state.avatarActions,
modalBottomSheetState = itemActionsBottomSheetState, modalBottomSheetState = itemActionsBottomSheetState,
onActionSelected = { state.eventSink(ConfigureRoomEvents.HandleAvatarAction(it)) } onActionSelected = { state.eventSink(ConfigureRoomEvents.HandleAvatarAction(it)) }
@ -221,16 +218,17 @@ fun RoomNameWithAvatar(
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Avatar( UnsavedAvatar(
avatarUri = avatarUri, avatarUri = avatarUri,
onClick = onAvatarClick, modifier = Modifier.clickable(onClick = onAvatarClick),
) )
LabelledTextField( LabelledTextField(
label = stringResource(R.string.screen_create_room_room_name_label), label = stringResource(R.string.screen_create_room_room_name_label),
value = roomName, value = roomName,
placeholder = stringResource(R.string.screen_create_room_room_name_placeholder), 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 @Preview
@Composable @Composable
fun ConfigureRoomViewLightPreview(@PreviewParameter(ConfigureRoomStateProvider::class) state: ConfigureRoomState) = 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"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_create_room">"New room"</string>
<string name="screen_create_room_action_invite_people">"Invite people"</string> <string name="screen_create_room_action_invite_people">"Invite friends to Element"</string>
<string name="screen_create_room_add_people_title">"Add people"</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_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_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> <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.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.features.createroom.impl.CreateRoomConfig import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.features.createroom.impl.CreateRoomDataStore import io.element.android.features.createroom.impl.CreateRoomDataStore
import io.element.android.features.createroom.impl.configureroom.avatar.AvatarAction
import io.element.android.features.createroom.impl.userlist.UserListDataStore import io.element.android.features.createroom.impl.userlist.UserListDataStore
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId

View file

@ -6,4 +6,4 @@
<string name="screen_invites_decline_direct_chat_title">"Decline chat"</string> <string name="screen_invites_decline_direct_chat_title">"Decline chat"</string>
<string name="screen_invites_empty_list">"No Invites"</string> <string name="screen_invites_empty_list">"No Invites"</string>
<string name="screen_invites_invited_you">"%1$s invited you"</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"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_subtitle">"Welcome to the %1$s Beta. Supercharged, for speed and simplicity."</string>
<string name="screen_onboarding_welcome_title">"Be in your Element"</string> <string name="screen_onboarding_welcome_title">"Be in your Element"</string>
</resources> </resources>

View file

@ -41,6 +41,8 @@ dependencies {
implementation(projects.libraries.elementresources) implementation(projects.libraries.elementresources)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)
implementation(projects.libraries.mediapickers.api)
implementation(projects.libraries.mediaupload.api)
api(projects.features.roomdetails.api) api(projects.features.roomdetails.api)
api(projects.libraries.usersearch.api) api(projects.libraries.usersearch.api)
api(projects.services.apperror.api) api(projects.services.apperror.api)
@ -52,7 +54,10 @@ dependencies {
testImplementation(libs.molecule.runtime) testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(libs.test.mockk)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.mediaupload.test)
testImplementation(projects.libraries.mediapickers.test)
testImplementation(projects.libraries.usersearch.test) testImplementation(projects.libraries.usersearch.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)
testImplementation(projects.features.leaveroom.fake) 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 dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint 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.invite.RoomInviteMembersNode
import io.element.android.features.roomdetails.impl.members.RoomMemberListNode import io.element.android.features.roomdetails.impl.members.RoomMemberListNode
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsNode import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsNode
@ -59,6 +60,9 @@ class RoomDetailsFlowNode @AssistedInject constructor(
@Parcelize @Parcelize
object RoomMemberList : NavTarget object RoomMemberList : NavTarget
@Parcelize
object RoomDetailsEdit : NavTarget
@Parcelize @Parcelize
object InviteMembers : NavTarget object InviteMembers : NavTarget
@ -74,12 +78,17 @@ class RoomDetailsFlowNode @AssistedInject constructor(
backstack.push(NavTarget.RoomMemberList) backstack.push(NavTarget.RoomMemberList)
} }
override fun editRoomDetails() {
backstack.push(NavTarget.RoomDetailsEdit)
}
override fun openInviteMembers() { override fun openInviteMembers() {
backstack.push(NavTarget.InviteMembers) backstack.push(NavTarget.InviteMembers)
} }
} }
createNode<RoomDetailsNode>(buildContext, listOf(roomDetailsCallback)) createNode<RoomDetailsNode>(buildContext, listOf(roomDetailsCallback))
} }
NavTarget.RoomMemberList -> { NavTarget.RoomMemberList -> {
val roomMemberListCallback = object : RoomMemberListNode.Callback { val roomMemberListCallback = object : RoomMemberListNode.Callback {
override fun openRoomMemberDetails(roomMemberId: UserId) { override fun openRoomMemberDetails(roomMemberId: UserId) {
@ -92,9 +101,15 @@ class RoomDetailsFlowNode @AssistedInject constructor(
} }
createNode<RoomMemberListNode>(buildContext, listOf(roomMemberListCallback)) createNode<RoomMemberListNode>(buildContext, listOf(roomMemberListCallback))
} }
NavTarget.RoomDetailsEdit -> {
createNode<RoomDetailsEditNode>(buildContext)
}
NavTarget.InviteMembers -> { NavTarget.InviteMembers -> {
createNode<RoomInviteMembersNode>(buildContext) createNode<RoomInviteMembersNode>(buildContext)
} }
is NavTarget.RoomMemberDetails -> { is NavTarget.RoomMemberDetails -> {
val plugins = listOf(RoomMemberDetailsNode.RoomMemberDetailsInput(navTarget.roomMemberId)) val plugins = listOf(RoomMemberDetailsNode.RoomMemberDetailsInput(navTarget.roomMemberId))
createNode<RoomMemberDetailsNode>(buildContext, plugins) createNode<RoomMemberDetailsNode>(buildContext, plugins)

View file

@ -46,6 +46,7 @@ class RoomDetailsNode @AssistedInject constructor(
interface Callback : Plugin { interface Callback : Plugin {
fun openRoomMemberList() fun openRoomMemberList()
fun openInviteMembers() fun openInviteMembers()
fun editRoomDetails()
} }
private val callbacks = plugins<Callback>() private val callbacks = plugins<Callback>()
@ -90,6 +91,10 @@ class RoomDetailsNode @AssistedInject constructor(
} }
} }
private fun onEditRoomDetails() {
callbacks.forEach { it.editRoomDetails() }
}
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val context = LocalContext.current val context = LocalContext.current
@ -103,10 +108,18 @@ class RoomDetailsNode @AssistedInject constructor(
this.onShareMember(context, roomMember) this.onShareMember(context, roomMember)
} }
fun onActionClicked(action: RoomDetailsAction) {
when (action) {
RoomDetailsAction.Edit -> onEditRoomDetails()
RoomDetailsAction.AddTopic -> onEditRoomDetails()
}
}
RoomDetailsView( RoomDetailsView(
state = state, state = state,
modifier = modifier, modifier = modifier,
goBack = this::navigateUp, goBack = this::navigateUp,
onActionClicked = ::onActionClicked,
onShareRoom = ::onShareRoom, onShareRoom = ::onShareRoom,
onShareMember = ::onShareMember, onShareMember = ::onShareMember,
openRoomMemberList = ::openRoomMemberList, 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.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMember 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.RoomMembershipState
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.ui.room.getDirectRoomMember import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
import javax.inject.Inject import javax.inject.Inject
@ -52,10 +53,23 @@ class RoomDetailsPresenter @Inject constructor(
val membersState by room.membersStateFlow.collectAsState() val membersState by room.membersStateFlow.collectAsState()
val memberCount by getMemberCount(membersState) val memberCount by getMemberCount(membersState)
val canInvite by getCanInvite(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 dmMember by room.getDirectRoomMember(membersState)
val roomMemberDetailsPresenter = roomMemberDetailsPresenter(dmMember) val roomMemberDetailsPresenter = roomMemberDetailsPresenter(dmMember)
val roomType = getRoomType(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) { fun handleEvents(event: RoomDetailsEvent) {
when (event) { when (event) {
is RoomDetailsEvent.LeaveRoom -> is RoomDetailsEvent.LeaveRoom ->
@ -70,10 +84,11 @@ class RoomDetailsPresenter @Inject constructor(
roomName = room.name ?: room.displayName, roomName = room.name ?: room.displayName,
roomAlias = room.alias, roomAlias = room.alias,
roomAvatarUrl = room.avatarUrl, roomAvatarUrl = room.avatarUrl,
roomTopic = room.topic, roomTopic = topicState,
memberCount = memberCount, memberCount = memberCount,
isEncrypted = room.isEncrypted, isEncrypted = room.isEncrypted,
canInvite = canInvite, canInvite = canInvite,
canEdit = canEditAvatar || canEditName || canEditTopic,
roomType = roomType.value, roomType = roomType.value,
roomMemberDetailsState = roomMemberDetailsState, roomMemberDetailsState = roomMemberDetailsState,
leaveRoomState = leaveRoomState, leaveRoomState = leaveRoomState,
@ -108,6 +123,15 @@ class RoomDetailsPresenter @Inject constructor(
return canInvite 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 @Composable
private fun getMemberCount(membersState: MatrixRoomMembersState): State<Async<Int>> { private fun getMemberCount(membersState: MatrixRoomMembersState): State<Async<Int>> {
return remember(membersState) { return remember(membersState) {

View file

@ -26,11 +26,12 @@ data class RoomDetailsState(
val roomName: String, val roomName: String,
val roomAlias: String?, val roomAlias: String?,
val roomAvatarUrl: String?, val roomAvatarUrl: String?,
val roomTopic: String?, val roomTopic: RoomTopicState,
val memberCount: Async<Int>, val memberCount: Async<Int>,
val isEncrypted: Boolean, val isEncrypted: Boolean,
val roomType: RoomDetailsType, val roomType: RoomDetailsType,
val roomMemberDetailsState: RoomMemberDetailsState?, val roomMemberDetailsState: RoomMemberDetailsState?,
val canEdit: Boolean,
val canInvite: Boolean, val canInvite: Boolean,
val leaveRoomState: LeaveRoomState, val leaveRoomState: LeaveRoomState,
val eventSink: (RoomDetailsEvent) -> Unit val eventSink: (RoomDetailsEvent) -> Unit
@ -40,3 +41,9 @@ sealed interface RoomDetailsType {
object Room : RoomDetailsType object Room : RoomDetailsType
data class Dm(val roomMember: RoomMember) : 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> override val values: Sequence<RoomDetailsState>
get() = sequenceOf( get() = sequenceOf(
aRoomDetailsState(), aRoomDetailsState(),
aRoomDetailsState().copy(roomTopic = null), aRoomDetailsState().copy(roomTopic = RoomTopicState.Hidden),
aRoomDetailsState().copy(roomTopic = RoomTopicState.CanAddTopic),
aRoomDetailsState().copy(isEncrypted = false), aRoomDetailsState().copy(isEncrypted = false),
aRoomDetailsState().copy(roomAlias = null), aRoomDetailsState().copy(roomAlias = null),
aRoomDetailsState().copy(memberCount = Async.Failure(Throwable())), aRoomDetailsState().copy(memberCount = Async.Failure(Throwable())),
aDmRoomDetailsState().copy(roomName = "Daniel"), aDmRoomDetailsState().copy(roomName = "Daniel"),
aDmRoomDetailsState(isDmMemberIgnored = true).copy(roomName = "Daniel"), aDmRoomDetailsState(isDmMemberIgnored = true).copy(roomName = "Daniel"),
aRoomDetailsState().copy(canInvite = true), aRoomDetailsState().copy(canInvite = true),
aRoomDetailsState().copy(canEdit = true),
// Add other state here // Add other state here
) )
} }
@ -64,14 +66,17 @@ fun aRoomDetailsState() = RoomDetailsState(
roomName = "Marketing", roomName = "Marketing",
roomAlias = "#marketing:domain.com", roomAlias = "#marketing:domain.com",
roomAvatarUrl = null, roomAvatarUrl = null,
roomTopic = "Welcome to #marketing, home of the Marketing team " + roomTopic = RoomTopicState.ExistingTopic(
"|| WIKI PAGE: https://domain.org/wiki/Marketing " + "Welcome to #marketing, home of the Marketing team " +
"|| MAIL iki/Marketing " + "|| WIKI PAGE: https://domain.org/wiki/Marketing " +
"|| MAI iki/Marketing " + "|| MAIL iki/Marketing " +
"|| MAI iki/Marketing...", "|| MAI iki/Marketing " +
"|| MAI iki/Marketing..."
),
memberCount = Async.Success(32), memberCount = Async.Success(32),
isEncrypted = true, isEncrypted = true,
canInvite = false, canInvite = false,
canEdit = false,
roomType = RoomDetailsType.Room, roomType = RoomDetailsType.Room,
roomMemberDetailsState = null, roomMemberDetailsState = null,
leaveRoomState = LeaveRoomState(), 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.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons 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.Lock
import androidx.compose.material.icons.outlined.Person import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.PersonAddAlt import androidx.compose.material.icons.outlined.PersonAddAlt
import androidx.compose.material.icons.outlined.Share 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.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector 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.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.LargeHeightPreview import io.element.android.libraries.designsystem.preview.LargeHeightPreview
import io.element.android.libraries.designsystem.theme.LocalColors 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.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.ui.strings.R as StringR import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
fun RoomDetailsView( fun RoomDetailsView(
state: RoomDetailsState, state: RoomDetailsState,
goBack: () -> Unit, goBack: () -> Unit,
onActionClicked: (RoomDetailsAction) -> Unit,
onShareRoom: () -> Unit, onShareRoom: () -> Unit,
onShareMember: (RoomMember) -> Unit, onShareMember: (RoomMember) -> Unit,
openRoomMemberList: () -> Unit, openRoomMemberList: () -> Unit,
invitePeople: () -> Unit, invitePeople: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
fun onShareMember() { fun onShareMember() {
onShareMember((state.roomType as RoomDetailsType.Dm).roomMember) onShareMember((state.roomType as RoomDetailsType.Dm).roomMember)
} }
@ -88,13 +99,18 @@ fun RoomDetailsView(
Scaffold( Scaffold(
modifier = modifier, modifier = modifier,
topBar = { topBar = {
TopAppBar(title = { }, navigationIcon = { BackButton(onClick = goBack) }) RoomDetailsTopBar(
goBack = goBack,
showEdit = state.canEdit,
onActionClicked = onActionClicked
)
}, },
) { padding -> ) { padding ->
Column(modifier = Modifier Column(
.padding(padding) modifier = Modifier
.consumeWindowInsets(padding) .padding(padding)
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.consumeWindowInsets(padding)
) { ) {
LeaveRoomView(state = state.leaveRoomState) LeaveRoomView(state = state.leaveRoomState)
@ -108,6 +124,7 @@ fun RoomDetailsView(
) )
MainActionsSection(onShareRoom = onShareRoom) MainActionsSection(onShareRoom = onShareRoom)
} }
is RoomDetailsType.Dm -> { is RoomDetailsType.Dm -> {
val member = state.roomType.roomMember val member = state.roomType.roomMember
RoomMemberHeaderSection( RoomMemberHeaderSection(
@ -120,8 +137,11 @@ fun RoomDetailsView(
} }
Spacer(Modifier.height(26.dp)) Spacer(Modifier.height(26.dp))
if (state.roomTopic != null) { if (state.roomTopic !is RoomTopicState.Hidden) {
TopicSection(roomTopic = state.roomTopic) TopicSection(
roomTopic = state.roomTopic,
onActionClicked = onActionClicked,
)
} }
if (state.roomType is RoomDetailsType.Room) { if (state.roomType is RoomDetailsType.Room) {
@ -129,10 +149,14 @@ fun RoomDetailsView(
MembersSection( MembersSection(
memberCount = memberCount, memberCount = memberCount,
isLoading = state.memberCount.isLoading(), isLoading = state.memberCount.isLoading(),
showInvite = state.canInvite,
openRoomMemberList = openRoomMemberList, openRoomMemberList = openRoomMemberList,
invitePeople = invitePeople,
) )
if (state.canInvite) {
InviteSection(
invitePeople = invitePeople
)
}
} }
if (state.isEncrypted) { 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 @Composable
internal fun MainActionsSection(onShareRoom: () -> Unit, modifier: Modifier = Modifier) { internal fun MainActionsSection(onShareRoom: () -> Unit, modifier: Modifier = Modifier) {
Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
@ -185,14 +248,26 @@ internal fun RoomHeaderSection(
} }
@Composable @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) { PreferenceCategory(title = stringResource(StringR.string.common_topic), modifier = modifier) {
Text( if (roomTopic is RoomTopicState.CanAddTopic) {
roomTopic, PreferenceText(
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 12.dp), title = stringResource(R.string.screen_room_details_add_topic_title),
style = MaterialTheme.typography.bodySmall, icon = Icons.Outlined.Add,
color = MaterialTheme.colorScheme.tertiary 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( internal fun MembersSection(
memberCount: Int?, memberCount: Int?,
isLoading: Boolean, isLoading: Boolean,
showInvite: Boolean,
invitePeople: () -> Unit,
openRoomMemberList: () -> Unit, openRoomMemberList: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -213,13 +286,20 @@ internal fun MembersSection(
onClick = openRoomMemberList, onClick = openRoomMemberList,
loadingCurrentValue = isLoading, loadingCurrentValue = isLoading,
) )
if (showInvite) { }
PreferenceText( }
title = stringResource(R.string.screen_room_details_invite_people_title),
icon = Icons.Outlined.PersonAddAlt, @Composable
onClick = invitePeople, 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( RoomDetailsView(
state = state, state = state,
goBack = {}, goBack = {},
onActionClicked = {},
onShareRoom = {}, onShareRoom = {},
onShareMember = {}, onShareMember = {},
openRoomMemberList = {}, 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_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_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_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_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_details_updating_room">"Updating room…"</string>
<string name="screen_room_member_list_pending_header_title">"Pending"</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> <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_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_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_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_leave_room_title">"Leave room"</string>
<string name="screen_room_details_people_title">"People"</string> <string name="screen_room_details_people_title">"People"</string>
<string name="screen_room_details_security_title">"Security"</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.RecompositionClock
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test 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.leaveroom.fake.LeaveRoomPresenterFake
import io.element.android.features.roomdetails.impl.RoomDetailsPresenter import io.element.android.features.roomdetails.impl.RoomDetailsPresenter
import io.element.android.features.roomdetails.impl.RoomDetailsType 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.aRoomMember
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
import io.element.android.libraries.architecture.Async 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.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.RoomMembershipState 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_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
@ -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.A_USER_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -48,9 +48,6 @@ import org.junit.Test
@ExperimentalCoroutinesApi @ExperimentalCoroutinesApi
class RoomDetailsPresenterTests { class RoomDetailsPresenterTests {
private val roomMembershipObserver = RoomMembershipObserver()
private val testCoroutineDispatchers = testCoroutineDispatchers()
private fun aRoomDetailsPresenter(room: MatrixRoom): RoomDetailsPresenter { private fun aRoomDetailsPresenter(room: MatrixRoom): RoomDetailsPresenter {
val roomMemberDetailsPresenterFactory = object : RoomMemberDetailsPresenter.Factory { val roomMemberDetailsPresenterFactory = object : RoomMemberDetailsPresenter.Factory {
override fun create(roomMemberId: UserId): RoomMemberDetailsPresenter { override fun create(roomMemberId: UserId): RoomMemberDetailsPresenter {
@ -68,12 +65,12 @@ class RoomDetailsPresenterTests {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
Truth.assertThat(initialState.roomId).isEqualTo(room.roomId.value) assertThat(initialState.roomId).isEqualTo(room.roomId.value)
Truth.assertThat(initialState.roomName).isEqualTo(room.name) assertThat(initialState.roomName).isEqualTo(room.name)
Truth.assertThat(initialState.roomAvatarUrl).isEqualTo(room.avatarUrl) assertThat(initialState.roomAvatarUrl).isEqualTo(room.avatarUrl)
Truth.assertThat(initialState.roomTopic).isEqualTo(room.topic) assertThat(initialState.roomTopic).isEqualTo(RoomTopicState.ExistingTopic(room.topic!!))
Truth.assertThat(initialState.memberCount).isEqualTo(Async.Uninitialized) assertThat(initialState.memberCount).isEqualTo(Async.Uninitialized)
Truth.assertThat(initialState.isEncrypted).isEqualTo(room.isEncrypted) assertThat(initialState.isEncrypted).isEqualTo(room.isEncrypted)
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
@ -93,22 +90,22 @@ class RoomDetailsPresenterTests {
}.test { }.test {
room.givenRoomMembersState(MatrixRoomMembersState.Unknown) room.givenRoomMembersState(MatrixRoomMembersState.Unknown)
val initialState = awaitItem() val initialState = awaitItem()
Truth.assertThat(initialState.memberCount).isEqualTo(Async.Uninitialized) assertThat(initialState.memberCount).isEqualTo(Async.Uninitialized)
skipItems(1) skipItems(1)
room.givenRoomMembersState(MatrixRoomMembersState.Pending(null)) room.givenRoomMembersState(MatrixRoomMembersState.Pending(null))
val loadingState = awaitItem() val loadingState = awaitItem()
Truth.assertThat(loadingState.memberCount).isEqualTo(Async.Loading(null)) assertThat(loadingState.memberCount).isEqualTo(Async.Loading(null))
room.givenRoomMembersState(MatrixRoomMembersState.Error(error)) room.givenRoomMembersState(MatrixRoomMembersState.Error(error))
skipItems(1) skipItems(1)
val failureState = awaitItem() 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)) room.givenRoomMembersState(MatrixRoomMembersState.Ready(roomMembers))
skipItems(1) skipItems(1)
val successState = awaitItem() val successState = awaitItem()
Truth.assertThat(successState.memberCount).isEqualTo(Async.Success(1)) assertThat(successState.memberCount).isEqualTo(Async.Success(1))
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
@ -122,7 +119,7 @@ class RoomDetailsPresenterTests {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
Truth.assertThat(initialState.roomName).isEqualTo(room.displayName) assertThat(initialState.roomName).isEqualTo(room.displayName)
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
@ -144,7 +141,7 @@ class RoomDetailsPresenterTests {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
Truth.assertThat(initialState.roomType).isEqualTo(RoomDetailsType.Dm(otherRoomMember)) assertThat(initialState.roomType).isEqualTo(RoomDetailsType.Dm(otherRoomMember))
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
@ -160,9 +157,9 @@ class RoomDetailsPresenterTests {
presenter.present() presenter.present()
}.test { }.test {
// Initially false // Initially false
Truth.assertThat(awaitItem().canInvite).isFalse() assertThat(awaitItem().canInvite).isFalse()
// Then the asynchronous check completes and it becomes true // Then the asynchronous check completes and it becomes true
Truth.assertThat(awaitItem().canInvite).isTrue() assertThat(awaitItem().canInvite).isTrue()
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
@ -177,7 +174,7 @@ class RoomDetailsPresenterTests {
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
Truth.assertThat(awaitItem().canInvite).isFalse() assertThat(awaitItem().canInvite).isFalse()
} }
} }
@ -190,7 +187,103 @@ class RoomDetailsPresenterTests {
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.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_300_Dark = Color(0xFF1D1F24)
val Compound_Gray_400_Light = Color(0xFFE1E6EC) val Compound_Gray_400_Light = Color(0xFFE1E6EC)
val Compound_Gray_400_Dark = Color(0xFF26282D) 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. * 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp 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.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
@ -36,8 +35,9 @@ fun LabelledTextField(
label: String, label: String,
value: String, value: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
placeholder: String = "", placeholder: String? = null,
maxLines: Int = 1, maxLines: Int = Int.MAX_VALUE,
singleLine: Boolean = false,
onValueChange: (String) -> Unit = {}, onValueChange: (String) -> Unit = {},
) { ) {
Column( Column(
@ -46,15 +46,17 @@ fun LabelledTextField(
) { ) {
Text( Text(
modifier = Modifier.padding(horizontal = 16.dp), modifier = Modifier.padding(horizontal = 16.dp),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
text = label text = label
) )
TextField( TextField(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
value = value, value = value,
placeholder = { Text(placeholder) }, placeholder = placeholder?.let { { Text(placeholder) } },
onValueChange = onValueChange, onValueChange = onValueChange,
singleLine = maxLines == 1, singleLine = singleLine,
maxLines = maxLines, maxLines = maxLines,
) )
} }
@ -72,14 +74,14 @@ fun LabelledTextFieldDarkPreview() = ElementPreviewDark { ContentToPreview() }
private fun ContentToPreview() { private fun ContentToPreview() {
Column { Column {
LabelledTextField( LabelledTextField(
label = stringResource(R.string.screen_create_room_room_name_label), label = "Room name",
value = "", value = "",
placeholder = stringResource(R.string.screen_create_room_room_name_placeholder), placeholder = "e.g. Product Sprint",
) )
LabelledTextField( LabelledTextField(
label = stringResource(R.string.screen_create_room_room_name_label), label = "Room name",
value = "a 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.Black_950
import io.element.android.libraries.designsystem.Compound_Gray_300_Dark import io.element.android.libraries.designsystem.Compound_Gray_300_Dark
import io.element.android.libraries.designsystem.DarkGrey 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_300
import io.element.android.libraries.designsystem.Gray_400 import io.element.android.libraries.designsystem.Gray_400
import io.element.android.libraries.designsystem.Compound_Gray_400_Dark import io.element.android.libraries.designsystem.Compound_Gray_400_Dark
@ -42,6 +43,7 @@ fun elementColorsDark() = ElementColors(
quinary = Gray_450, quinary = Gray_450,
gray300 = Compound_Gray_300_Dark, gray300 = Compound_Gray_300_Dark,
gray400 = Compound_Gray_400_Dark, gray400 = Compound_Gray_400_Dark,
gray1400 = Gray_1400_Dark,
textActionCritical = TextColorCriticalDark, textActionCritical = TextColorCriticalDark,
isLight = false, isLight = false,
) )

View file

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

View file

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

View file

@ -89,4 +89,14 @@ interface MatrixRoom : Closeable {
suspend fun inviteUserById(id: UserId): Result<Unit> suspend fun inviteUserById(id: UserId): Result<Unit>
suspend fun canInvite(): Result<Boolean> 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.media.VideoInfo
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.roomMembers import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline 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.media.map
import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline
import io.element.android.services.toolbox.api.systemclock.SystemClock import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow 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) { override suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo): Result<Unit> = withContext(coroutineDispatchers.io) {
runCatching { runCatching {
innerRoom.sendImage(file.path, thumbnailFile.path, imageInfo.map()) innerRoom.sendImage(file.path, thumbnailFile.path, imageInfo.map())
@ -247,4 +255,33 @@ class RustMatrixRoom(
innerRoom.sendFile(file.path, fileInfo.map()) 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.media.VideoInfo
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline 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_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_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 rejectInviteResult = Result.success(Unit)
private var inviteUserResult = Result.success(Unit) private var inviteUserResult = Result.success(Unit)
private var canInviteResult = Result.success(true) private var canInviteResult = Result.success(true)
private val canSendStateResults = mutableMapOf<StateEventType, Result<Boolean>>()
private var sendMediaResult = Result.success(Unit) 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 var sendMediaCount = 0
private set private set
@ -75,6 +82,18 @@ class FakeMatrixRoom(
var invitedUserId: UserId? = null var invitedUserId: UserId? = null
private set 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 private var leaveRoomError: Throwable? = null
override val membersStateFlow: MutableStateFlow<MatrixRoomMembersState> = MutableStateFlow(MatrixRoomMembersState.Unknown) override val membersStateFlow: MutableStateFlow<MatrixRoomMembersState> = MutableStateFlow(MatrixRoomMembersState.Unknown)
@ -151,6 +170,10 @@ class FakeMatrixRoom(
return canInviteResult 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 sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo): Result<Unit> = fakeSendMedia()
override suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo): 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 override fun close() = Unit
fun givenLeaveRoomError(throwable: Throwable?) { fun givenLeaveRoomError(throwable: Throwable?) {
@ -204,6 +247,10 @@ class FakeMatrixRoom(
canInviteResult = result canInviteResult = result
} }
fun givenCanSendStateResult(type: StateEventType, result: Result<Boolean>) {
canSendStateResults[type] = result
}
fun givenIgnoreResult(result: Result<Unit>) { fun givenIgnoreResult(result: Result<Unit>) {
ignoreResult = result ignoreResult = result
} }
@ -215,4 +262,20 @@ class FakeMatrixRoom(
fun givenSendMediaResult(result: Result<Unit>) { fun givenSendMediaResult(result: Result<Unit>) {
sendMediaResult = result 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) @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.clickable
import androidx.compose.foundation.layout.fillMaxWidth 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.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheetLayout 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.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Composable @Composable
fun AvatarActionListView( fun AvatarActionBottomSheet(
actions: ImmutableList<AvatarAction>, actions: ImmutableList<AvatarAction>,
modalBottomSheetState: ModalBottomSheetState, modalBottomSheetState: ModalBottomSheetState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -62,7 +63,7 @@ fun AvatarActionListView(
modifier = modifier, modifier = modifier,
sheetState = modalBottomSheetState, sheetState = modalBottomSheetState,
sheetContent = { sheetContent = {
SheetContent( AvatarActionBottomSheetContent(
actions = actions, actions = actions,
onActionClicked = ::onItemActionClicked, onActionClicked = ::onItemActionClicked,
modifier = Modifier modifier = Modifier
@ -74,7 +75,7 @@ fun AvatarActionListView(
} }
@Composable @Composable
private fun SheetContent( private fun AvatarActionBottomSheetContent(
actions: ImmutableList<AvatarAction>, actions: ImmutableList<AvatarAction>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onActionClicked: (AvatarAction) -> Unit = { }, onActionClicked: (AvatarAction) -> Unit = { },
@ -107,17 +108,17 @@ private fun SheetContent(
@Preview @Preview
@Composable @Composable
fun SheetContentLightPreview() = fun AvatarActionBottomSheetLightPreview() =
ElementPreviewLight { ContentToPreview() } ElementPreviewLight { ContentToPreview() }
@Preview @Preview
@Composable @Composable
fun SheetContentDarkPreview() = fun AvatarActionBottomSheetDarkPreview() =
ElementPreviewDark { ContentToPreview() } ElementPreviewDark { ContentToPreview() }
@Composable @Composable
private fun ContentToPreview() { private fun ContentToPreview() {
AvatarActionListView( AvatarActionBottomSheet(
actions = persistentListOf(AvatarAction.TakePhoto, AvatarAction.ChoosePhoto, AvatarAction.Remove), actions = persistentListOf(AvatarAction.TakePhoto, AvatarAction.ChoosePhoto, AvatarAction.Remove),
modalBottomSheetState = ModalBottomSheetState( modalBottomSheetState = ModalBottomSheetState(
initialValue = ModalBottomSheetValue.Expanded initialValue = ModalBottomSheetValue.Expanded

View file

@ -14,11 +14,10 @@
* limitations under the License. * 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 android.net.Uri
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size 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.LocalColors
import io.element.android.libraries.designsystem.theme.components.Icon 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 @Composable
fun Avatar( fun UnsavedAvatar(
avatarUri: Uri?, avatarUri: Uri?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onClick: () -> Unit = {},
) { ) {
val commonModifier = modifier val commonModifier = modifier
.size(70.dp) .size(70.dp)
.clip(CircleShape) .clip(CircleShape)
.clickable(onClick = onClick)
if (avatarUri != null) { if (avatarUri != null) {
val context = LocalContext.current val context = LocalContext.current
@ -82,16 +84,16 @@ fun Avatar(
@Preview @Preview
@Composable @Composable
fun AvatarLightPreview() = ElementPreviewLight { ContentToPreview() } fun UnsavedAvatarLightPreview() = ElementPreviewLight { ContentToPreview() }
@Preview @Preview
@Composable @Composable
fun AvatarDarkPreview() = ElementPreviewDark { ContentToPreview() } fun UnsavedAvatarDarkPreview() = ElementPreviewDark { ContentToPreview() }
@Composable @Composable
private fun ContentToPreview() { private fun ContentToPreview() {
Row { Row {
Avatar(null) UnsavedAvatar(null)
Avatar(Uri.EMPTY) UnsavedAvatar(Uri.EMPTY)
} }
} }

View file

@ -14,7 +14,7 @@
* limitations under the License. * 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.annotation.StringRes
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:55d774d5e61f8832162859ec9d22299efa5b0728cfc3308b13fb11df31c5130e oid sha256:20e9edff7567936627718427349a2ca7bf318a909b6ca206304deab0149a796d
size 13790 size 13934

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b96cf938377ee0f02e0cf2d5896eb83935d9416a5b87849ed81caed4db5e90f2 oid sha256:ab9f1e15c5a071be9c2f63aa28e6fbd57c360960899f808cead275ef67a54ae6
size 41264 size 41444

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:2ed229820d7cbd6b550e98116ec3bf688c52957e2a57ab296bfc5abd212345b1 oid sha256:0b2350c880e73ce285bfeafafc851c6973e0372330ab6f4daa16d078ed6a81c9
size 13784 size 13884

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:92fe30f0927b8be4ffc384548e6731e7d5e347dc5caa1c84251f2a932ee1873c oid sha256:71d10fb1a2102466ab759442a1e006a3f5f85cb80f9f885cb5d18c8ded570bef
size 38848 size 38945

View file

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

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a9da3afa82783ded1e686a36eb969b8e097de853e9c6dadea24884f7975c0720 oid sha256:533ccd5b4e27b98ae4155e70d8cdde8a3c263ca613cc3e0ce31c2fc1d52758b8
size 63729 size 63361

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e2c200874d6fa8f4fe07da69a7879eb264fb8941c1ab9654162cb76586c52936 oid sha256:7a036b609372a8c2f20307ca9f13b619973da8da45e81a019af1fdafadda64de
size 103326 size 102275

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e48bb979a1d328871157847862723b8342cd8c2704750045bbf24592e143a5e0 oid sha256:4032529e222fb9d14d4dc1fd215c8aae5d63326809df7cec449011d7904a60d2
size 57874 size 58355

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:4320fd1900a120a737ba2a56e6c1f55cf671eb0e8c1791dc605994d333feb0c2 oid sha256:2bf49968f5b07ca6798c4c8670a9e173ef3a511c6c60ed8d55d652bb042b8a8b
size 96888 size 96370

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:6f36c2b4f4266048d5295df08f380e4128630d11cce7ac11cf3a0eaaa5594d61 oid sha256:4fddc08d41da556719424efbc75a1a80a57202095ce082124bc0bca08e54c74b
size 19924 size 21415

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:23b9e996b8f0cc2efcb3adb6294bfa7b4a53fefbb7b6ee07add4105da9b9d40e oid sha256:2639e6662a1c78ff0f6091fb72b5fc06b486aa08e3beed78e4877b9282ce98d6
size 18984 size 20309

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:516e972069292f31625847ca39de06ea402ce1606c97d84f930c18eaa2448cfa oid sha256:9ba1f70268339a290e35e2127d1a3fa03840e69dc143631ef2fe29f22ddf646a
size 13759 size 15363

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b387f10903926ba9ed30b7d213314388eebf23743cbbdbcf4b613cf3136f64aa oid sha256:01b7d6a33e3591a54bcf602bac34155b85056ebdf600c511858efba55ea43cf4
size 41524 size 43034

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:0af8c7149fd4266c68c0e4b8b9a699bc0b3fc0b05fea3f2e2544abc884f70c5b oid sha256:cdb5e42bc94ca908c76b5da491ed712169876e8f2ea80534f2e722b7280ba72d
size 11988 size 13602

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:8eb2fcdb57b7f1ba28990f9576fad6dce156fe0c5c9ae491797040fc22ae4f7b oid sha256:656cec478d23350ed5c6e08b6cd9579e497999d6a7a8aa879af7dd223fefa98b
size 39516 size 41088

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:1e4d3bc53e6a86957218247b614be6bbc78cfb35ce49565f2243f50d6ef94fe3 oid sha256:456b9c09e0ff314543fa6a0e54d7e6e41f346e3fd2ff33f9ba1ea3ae3168d629
size 14216 size 15839

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:213777e4ace6f9c33209db37bffff2ca6dba13e540e9d07be82239d261ee8e3f oid sha256:d76042bbb94307f96dc99d5077b58204b8f30a195408739271321570c218d0da
size 63951 size 65441

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:cb1178f063eafb21ba01b93310d84335afbc4e6ad1fc99d1776578fe3f7eaa07 oid sha256:22bda941040d3ad60d709517f07a27933ae25a5a69464e492fe1d0f9c4d7c405
size 49237 size 50488

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:8ddec11fd636d345c49cf981ce7498ac2d94027d4b223b257b64288bb0254766 oid sha256:bcc75c47150e577714221b5d574a67c69051cf2f4439f8b7c929620509d87562
size 13328 size 14668

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:da5ba4637d999c8654e38a86ad0b26c75a6fe10a6988eb821b24cf91da2274e5 oid sha256:1df29f2bd4090162dcbf7336971c366b53b49390f5f41cc6df6c6f506f01ab30
size 39070 size 40464

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:c57c20fe79d0dbb0ca25571fa2ab3ba6e06f3a9c6634e96fac3ed0155c2ea106 oid sha256:d488d43188f491cc578d30a70d05c90d6c436ca8fad88a36b85a1ef5bc90e88b
size 11160 size 12564

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:8e43279132a7536516cf267599f877f7c729f76be51d6319944f74c57af0e282 oid sha256:900065642b4eb9a90a3a5d1bbbb5f03e9057b08e5e8289e535fd9237ac15e497
size 36493 size 37824

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:933c5ac4478699e9e713af559025ab675a8bdc93b15c42ff27fe5525fa3b668a oid sha256:b457fe4bc8ff8a26612819abbbac6440b539e61a8f00fb4171ad38dfb131840a
size 13166 size 14553

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:3a66a39bf18ba35abfc87dea22ca019eea98727bf246142cb034649c5b1772bd oid sha256:8384acb8fef843e0f06108d230c40bda5db7a95932568d37cbd610eaf09acc23
size 61041 size 62392

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:ca9a896e2dd3cf47792467f052f2d45b555d99ccbd91436ef4c03dfca66836ab oid sha256:307bfb8ffa9088d7d474c0d51e5e8d54bd6598b527357fe67380c755c2af75f0
size 47542 size 48893

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:c3e225aea9018c660e16ce5ee6e1e343b439b96264b274c5d722b05c6c9a5d48 oid sha256:53015a9106ad2c3a24b9073e142db6fb3ba0e40ef3e87b1ba2142ec04066eba6
size 52064 size 53644

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:2372fe63e9fcc2c6529622270592437b880211acdcf7ef8be36dfc9fe41e0e5b oid sha256:c3e225aea9018c660e16ce5ee6e1e343b439b96264b274c5d722b05c6c9a5d48
size 66807 size 52064

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:dce5d8c8dacb7c095cd22d590fc4fd9bc2cb9b13e0d4edbdd52387226b37b4ec oid sha256:2372fe63e9fcc2c6529622270592437b880211acdcf7ef8be36dfc9fe41e0e5b
size 70253 size 66807

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d3a1afc10771257294d9f22d26ed8a81d04cd4b1f31d9fb9fc1065a8216230a9 oid sha256:dce5d8c8dacb7c095cd22d590fc4fd9bc2cb9b13e0d4edbdd52387226b37b4ec
size 67658 size 70253

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:7a3b23895b133b7bba7300fbf31fd70daa83f558e29e8c09dfc7789ce0bc04d6 oid sha256:d3a1afc10771257294d9f22d26ed8a81d04cd4b1f31d9fb9fc1065a8216230a9
size 61861 size 67658

View file

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

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b22c207ac5f185c1c7eaa30ffe43acb2a1e8da06379f15f66ff96797b8ea9158 oid sha256:7045a9ced4691ec52451b6a31d2454ddc82c04b767c96a3dd4c3f81addcb8f0c
size 48297 size 50010

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a7a98c0125cc58a130b8ebfbd5f2b8fcfa65e02b0c6a151c0b594208a8801c96 oid sha256:b22c207ac5f185c1c7eaa30ffe43acb2a1e8da06379f15f66ff96797b8ea9158
size 60894 size 48297

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:998819b0aeb15712bb53e982e3756cf2e4a4069a2a13f7955e10b873e3e736bd oid sha256:a7a98c0125cc58a130b8ebfbd5f2b8fcfa65e02b0c6a151c0b594208a8801c96
size 63698 size 60894

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:cb8dc0ca3fa34ffa40ea43934ee00d0c48c9b7ce721274763c093eee83d8cff9 oid sha256:998819b0aeb15712bb53e982e3756cf2e4a4069a2a13f7955e10b873e3e736bd
size 62187 size 63698

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e403d076b9d28e7fd06493399d4b7c405d99f329fb1a298c41fb445a1e9ec466 oid sha256:cb8dc0ca3fa34ffa40ea43934ee00d0c48c9b7ce721274763c093eee83d8cff9
size 57215 size 62187

View file

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

View file

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

View file

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

View file

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