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:
parent
5de90c3871
commit
5d0fb45ff6
100 changed files with 2031 additions and 219 deletions
1
changelog.d/242.feature
Normal file
1
changelog.d/242.feature
Normal file
|
|
@ -0,0 +1 @@
|
|||
[Create and join rooms] Update room properties from room details
|
||||
|
|
@ -48,8 +48,8 @@ dependencies {
|
|||
implementation(projects.libraries.androidutils)
|
||||
implementation(projects.libraries.mediapickers.api)
|
||||
implementation(projects.libraries.mediaupload.api)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(projects.libraries.usersearch.impl)
|
||||
implementation(libs.coil.compose)
|
||||
api(projects.features.createroom.api)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@
|
|||
package io.element.android.features.createroom.impl.configureroom
|
||||
|
||||
import io.element.android.features.createroom.impl.CreateRoomConfig
|
||||
import io.element.android.features.createroom.impl.configureroom.avatar.AvatarAction
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.ui.media.AvatarAction
|
||||
|
||||
sealed interface ConfigureRoomEvents {
|
||||
data class RoomNameChanged(val name: String) : ConfigureRoomEvents
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import io.element.android.features.createroom.impl.CreateRoomConfig
|
||||
import io.element.android.features.createroom.impl.CreateRoomDataStore
|
||||
import io.element.android.features.createroom.impl.configureroom.avatar.AvatarAction
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.architecture.execute
|
||||
|
|
@ -37,6 +36,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
|
|||
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
|
||||
import io.element.android.libraries.matrix.api.createroom.RoomPreset
|
||||
import io.element.android.libraries.matrix.api.createroom.RoomVisibility
|
||||
import io.element.android.libraries.matrix.ui.media.AvatarAction
|
||||
import io.element.android.libraries.mediapickers.api.PickerProvider
|
||||
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@
|
|||
|
||||
package io.element.android.features.createroom.impl.configureroom
|
||||
|
||||
import io.element.android.libraries.matrix.ui.media.AvatarAction
|
||||
import io.element.android.features.createroom.impl.CreateRoomConfig
|
||||
import io.element.android.features.createroom.impl.configureroom.avatar.AvatarAction
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
package io.element.android.features.createroom.impl.configureroom
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
|
@ -24,9 +25,11 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
|||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.ModalBottomSheetValue
|
||||
import androidx.compose.material.rememberModalBottomSheetState
|
||||
|
|
@ -46,11 +49,9 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.element.android.features.createroom.impl.R
|
||||
import io.element.android.features.createroom.impl.components.Avatar
|
||||
import io.element.android.features.createroom.impl.components.LabelledTextField
|
||||
import io.element.android.features.createroom.impl.components.RoomPrivacyOption
|
||||
import io.element.android.features.createroom.impl.configureroom.avatar.AvatarActionListView
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.designsystem.components.LabelledTextField
|
||||
import io.element.android.libraries.designsystem.components.ProgressDialog
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
|
||||
|
|
@ -61,7 +62,9 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold
|
|||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet
|
||||
import io.element.android.libraries.matrix.ui.components.SelectedUsersList
|
||||
import io.element.android.libraries.matrix.ui.components.UnsavedAvatar
|
||||
import kotlinx.coroutines.launch
|
||||
import io.element.android.libraries.ui.strings.R as StringR
|
||||
|
||||
|
|
@ -105,54 +108,48 @@ fun ConfigureRoomView(
|
|||
)
|
||||
}
|
||||
) { padding ->
|
||||
LazyColumn(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
.imePadding()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.consumeWindowInsets(padding),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
item {
|
||||
RoomNameWithAvatar(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
avatarUri = state.config.avatarUri,
|
||||
roomName = state.config.roomName.orEmpty(),
|
||||
onAvatarClick = ::onAvatarClicked,
|
||||
onRoomNameChanged = { state.eventSink(ConfigureRoomEvents.RoomNameChanged(it)) },
|
||||
)
|
||||
}
|
||||
item {
|
||||
RoomTopic(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
topic = state.config.topic.orEmpty(),
|
||||
onTopicChanged = { state.eventSink(ConfigureRoomEvents.TopicChanged(it)) },
|
||||
)
|
||||
}
|
||||
RoomNameWithAvatar(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
avatarUri = state.config.avatarUri,
|
||||
roomName = state.config.roomName.orEmpty(),
|
||||
onAvatarClick = ::onAvatarClicked,
|
||||
onRoomNameChanged = { state.eventSink(ConfigureRoomEvents.RoomNameChanged(it)) },
|
||||
)
|
||||
RoomTopic(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
topic = state.config.topic.orEmpty(),
|
||||
onTopicChanged = { state.eventSink(ConfigureRoomEvents.TopicChanged(it)) },
|
||||
)
|
||||
if (state.config.invites.isNotEmpty()) {
|
||||
item {
|
||||
SelectedUsersList(
|
||||
contentPadding = PaddingValues(horizontal = 24.dp),
|
||||
selectedUsers = state.config.invites,
|
||||
onUserRemoved = {
|
||||
focusManager.clearFocus()
|
||||
state.eventSink(ConfigureRoomEvents.RemoveFromSelection(it))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
RoomPrivacyOptions(
|
||||
modifier = Modifier.padding(bottom = 40.dp),
|
||||
selected = state.config.privacy,
|
||||
onOptionSelected = {
|
||||
SelectedUsersList(
|
||||
contentPadding = PaddingValues(horizontal = 24.dp),
|
||||
selectedUsers = state.config.invites,
|
||||
onUserRemoved = {
|
||||
focusManager.clearFocus()
|
||||
state.eventSink(ConfigureRoomEvents.RoomPrivacyChanged(it.privacy))
|
||||
state.eventSink(ConfigureRoomEvents.RemoveFromSelection(it))
|
||||
},
|
||||
)
|
||||
}
|
||||
RoomPrivacyOptions(
|
||||
modifier = Modifier.padding(bottom = 40.dp),
|
||||
selected = state.config.privacy,
|
||||
onOptionSelected = {
|
||||
focusManager.clearFocus()
|
||||
state.eventSink(ConfigureRoomEvents.RoomPrivacyChanged(it.privacy))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AvatarActionListView(
|
||||
AvatarActionBottomSheet(
|
||||
actions = state.avatarActions,
|
||||
modalBottomSheetState = itemActionsBottomSheetState,
|
||||
onActionSelected = { state.eventSink(ConfigureRoomEvents.HandleAvatarAction(it)) }
|
||||
|
|
@ -221,16 +218,17 @@ fun RoomNameWithAvatar(
|
|||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Avatar(
|
||||
UnsavedAvatar(
|
||||
avatarUri = avatarUri,
|
||||
onClick = onAvatarClick,
|
||||
modifier = Modifier.clickable(onClick = onAvatarClick),
|
||||
)
|
||||
|
||||
LabelledTextField(
|
||||
label = stringResource(R.string.screen_create_room_room_name_label),
|
||||
value = roomName,
|
||||
placeholder = stringResource(R.string.screen_create_room_room_name_placeholder),
|
||||
onValueChange = onRoomNameChanged
|
||||
singleLine = true,
|
||||
onValueChange = onRoomNameChanged,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -269,6 +267,13 @@ fun RoomPrivacyOptions(
|
|||
}
|
||||
}
|
||||
|
||||
private fun Modifier.clearFocusOnTap(focusManager: FocusManager): Modifier =
|
||||
pointerInput(Unit) {
|
||||
detectTapGestures(onTap = {
|
||||
focusManager.clearFocus()
|
||||
})
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ConfigureRoomViewLightPreview(@PreviewParameter(ConfigureRoomStateProvider::class) state: ConfigureRoomState) =
|
||||
|
|
@ -286,10 +291,3 @@ private fun ContentToPreview(state: ConfigureRoomState) {
|
|||
)
|
||||
}
|
||||
|
||||
private fun Modifier.clearFocusOnTap(focusManager: FocusManager): Modifier =
|
||||
pointerInput(Unit) {
|
||||
detectTapGestures(onTap = {
|
||||
focusManager.clearFocus()
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_create_room_action_create_room">"New room"</string>
|
||||
<string name="screen_create_room_action_invite_people">"Invite people"</string>
|
||||
<string name="screen_create_room_add_people_title">"Add people"</string>
|
||||
<string name="screen_create_room_action_invite_people">"Invite friends to Element"</string>
|
||||
<string name="screen_create_room_add_people_title">"Invite people"</string>
|
||||
<string name="screen_create_room_error_creating_room">"An error occurred when creating the room"</string>
|
||||
<string name="screen_create_room_private_option_description">"Messages in this room are encrypted. Encryption can’t be disabled afterwards."</string>
|
||||
<string name="screen_create_room_private_option_title">"Private room (invite only)"</string>
|
||||
|
|
|
|||
|
|
@ -21,9 +21,9 @@ import app.cash.molecule.RecompositionClock
|
|||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.ui.media.AvatarAction
|
||||
import io.element.android.features.createroom.impl.CreateRoomConfig
|
||||
import io.element.android.features.createroom.impl.CreateRoomDataStore
|
||||
import io.element.android.features.createroom.impl.configureroom.avatar.AvatarAction
|
||||
import io.element.android.features.createroom.impl.userlist.UserListDataStore
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
|
|
|
|||
|
|
@ -6,4 +6,4 @@
|
|||
<string name="screen_invites_decline_direct_chat_title">"Decline chat"</string>
|
||||
<string name="screen_invites_empty_list">"No Invites"</string>
|
||||
<string name="screen_invites_invited_you">"%1$s invited you"</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_onboarding_sign_in_manually">"Sign in manually"</string>
|
||||
<string name="screen_onboarding_sign_in_with_qr_code">"Sign in with QR code"</string>
|
||||
<string name="screen_onboarding_sign_up">"Create account"</string>
|
||||
<string name="screen_onboarding_subtitle">"Communicate and collaborate securely"</string>
|
||||
<string name="screen_onboarding_welcome_subtitle">"Welcome to the %1$s Beta. Supercharged, for speed and simplicity."</string>
|
||||
<string name="screen_onboarding_welcome_title">"Be in your Element"</string>
|
||||
</resources>
|
||||
|
|
@ -41,6 +41,8 @@ dependencies {
|
|||
implementation(projects.libraries.elementresources)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
implementation(projects.libraries.androidutils)
|
||||
implementation(projects.libraries.mediapickers.api)
|
||||
implementation(projects.libraries.mediaupload.api)
|
||||
api(projects.features.roomdetails.api)
|
||||
api(projects.libraries.usersearch.api)
|
||||
api(projects.services.apperror.api)
|
||||
|
|
@ -52,7 +54,10 @@ dependencies {
|
|||
testImplementation(libs.molecule.runtime)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(libs.test.mockk)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.libraries.mediaupload.test)
|
||||
testImplementation(projects.libraries.mediapickers.test)
|
||||
testImplementation(projects.libraries.usersearch.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
testImplementation(projects.features.leaveroom.fake)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -29,6 +29,7 @@ import dagger.assisted.Assisted
|
|||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
|
||||
import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditNode
|
||||
import io.element.android.features.roomdetails.impl.invite.RoomInviteMembersNode
|
||||
import io.element.android.features.roomdetails.impl.members.RoomMemberListNode
|
||||
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsNode
|
||||
|
|
@ -59,6 +60,9 @@ class RoomDetailsFlowNode @AssistedInject constructor(
|
|||
@Parcelize
|
||||
object RoomMemberList : NavTarget
|
||||
|
||||
@Parcelize
|
||||
object RoomDetailsEdit : NavTarget
|
||||
|
||||
@Parcelize
|
||||
object InviteMembers : NavTarget
|
||||
|
||||
|
|
@ -74,12 +78,17 @@ class RoomDetailsFlowNode @AssistedInject constructor(
|
|||
backstack.push(NavTarget.RoomMemberList)
|
||||
}
|
||||
|
||||
override fun editRoomDetails() {
|
||||
backstack.push(NavTarget.RoomDetailsEdit)
|
||||
}
|
||||
|
||||
override fun openInviteMembers() {
|
||||
backstack.push(NavTarget.InviteMembers)
|
||||
}
|
||||
}
|
||||
createNode<RoomDetailsNode>(buildContext, listOf(roomDetailsCallback))
|
||||
}
|
||||
|
||||
NavTarget.RoomMemberList -> {
|
||||
val roomMemberListCallback = object : RoomMemberListNode.Callback {
|
||||
override fun openRoomMemberDetails(roomMemberId: UserId) {
|
||||
|
|
@ -92,9 +101,15 @@ class RoomDetailsFlowNode @AssistedInject constructor(
|
|||
}
|
||||
createNode<RoomMemberListNode>(buildContext, listOf(roomMemberListCallback))
|
||||
}
|
||||
|
||||
NavTarget.RoomDetailsEdit -> {
|
||||
createNode<RoomDetailsEditNode>(buildContext)
|
||||
}
|
||||
|
||||
NavTarget.InviteMembers -> {
|
||||
createNode<RoomInviteMembersNode>(buildContext)
|
||||
}
|
||||
|
||||
is NavTarget.RoomMemberDetails -> {
|
||||
val plugins = listOf(RoomMemberDetailsNode.RoomMemberDetailsInput(navTarget.roomMemberId))
|
||||
createNode<RoomMemberDetailsNode>(buildContext, plugins)
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ class RoomDetailsNode @AssistedInject constructor(
|
|||
interface Callback : Plugin {
|
||||
fun openRoomMemberList()
|
||||
fun openInviteMembers()
|
||||
fun editRoomDetails()
|
||||
}
|
||||
|
||||
private val callbacks = plugins<Callback>()
|
||||
|
|
@ -90,6 +91,10 @@ class RoomDetailsNode @AssistedInject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun onEditRoomDetails() {
|
||||
callbacks.forEach { it.editRoomDetails() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val context = LocalContext.current
|
||||
|
|
@ -103,10 +108,18 @@ class RoomDetailsNode @AssistedInject constructor(
|
|||
this.onShareMember(context, roomMember)
|
||||
}
|
||||
|
||||
fun onActionClicked(action: RoomDetailsAction) {
|
||||
when (action) {
|
||||
RoomDetailsAction.Edit -> onEditRoomDetails()
|
||||
RoomDetailsAction.AddTopic -> onEditRoomDetails()
|
||||
}
|
||||
}
|
||||
|
||||
RoomDetailsView(
|
||||
state = state,
|
||||
modifier = modifier,
|
||||
goBack = this::navigateUp,
|
||||
onActionClicked = ::onActionClicked,
|
||||
onShareRoom = ::onShareRoom,
|
||||
onShareMember = ::onShareMember,
|
||||
openRoomMemberList = ::openRoomMemberList,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
|
|||
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||
import io.element.android.libraries.matrix.api.room.StateEventType
|
||||
import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
|
||||
import javax.inject.Inject
|
||||
|
||||
|
|
@ -52,10 +53,23 @@ class RoomDetailsPresenter @Inject constructor(
|
|||
val membersState by room.membersStateFlow.collectAsState()
|
||||
val memberCount by getMemberCount(membersState)
|
||||
val canInvite by getCanInvite(membersState)
|
||||
val canEditName by getCanSendStateEvent(membersState, StateEventType.ROOM_NAME)
|
||||
val canEditAvatar by getCanSendStateEvent(membersState, StateEventType.ROOM_AVATAR)
|
||||
val canEditTopic by getCanSendStateEvent(membersState, StateEventType.ROOM_TOPIC)
|
||||
val dmMember by room.getDirectRoomMember(membersState)
|
||||
val roomMemberDetailsPresenter = roomMemberDetailsPresenter(dmMember)
|
||||
val roomType = getRoomType(dmMember)
|
||||
|
||||
val topicState = remember(canEditTopic, room.topic) {
|
||||
val topic = room.topic
|
||||
|
||||
when {
|
||||
!topic.isNullOrBlank() -> RoomTopicState.ExistingTopic(topic)
|
||||
canEditTopic -> RoomTopicState.CanAddTopic
|
||||
else -> RoomTopicState.Hidden
|
||||
}
|
||||
}
|
||||
|
||||
fun handleEvents(event: RoomDetailsEvent) {
|
||||
when (event) {
|
||||
is RoomDetailsEvent.LeaveRoom ->
|
||||
|
|
@ -70,10 +84,11 @@ class RoomDetailsPresenter @Inject constructor(
|
|||
roomName = room.name ?: room.displayName,
|
||||
roomAlias = room.alias,
|
||||
roomAvatarUrl = room.avatarUrl,
|
||||
roomTopic = room.topic,
|
||||
roomTopic = topicState,
|
||||
memberCount = memberCount,
|
||||
isEncrypted = room.isEncrypted,
|
||||
canInvite = canInvite,
|
||||
canEdit = canEditAvatar || canEditName || canEditTopic,
|
||||
roomType = roomType.value,
|
||||
roomMemberDetailsState = roomMemberDetailsState,
|
||||
leaveRoomState = leaveRoomState,
|
||||
|
|
@ -108,6 +123,15 @@ class RoomDetailsPresenter @Inject constructor(
|
|||
return canInvite
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun getCanSendStateEvent(membersState: MatrixRoomMembersState, type: StateEventType): State<Boolean> {
|
||||
val canSendEvent = remember(membersState) { mutableStateOf(false) }
|
||||
LaunchedEffect(membersState) {
|
||||
canSendEvent.value = room.canSendStateEvent(type).getOrElse { false }
|
||||
}
|
||||
return canSendEvent
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun getMemberCount(membersState: MatrixRoomMembersState): State<Async<Int>> {
|
||||
return remember(membersState) {
|
||||
|
|
|
|||
|
|
@ -26,11 +26,12 @@ data class RoomDetailsState(
|
|||
val roomName: String,
|
||||
val roomAlias: String?,
|
||||
val roomAvatarUrl: String?,
|
||||
val roomTopic: String?,
|
||||
val roomTopic: RoomTopicState,
|
||||
val memberCount: Async<Int>,
|
||||
val isEncrypted: Boolean,
|
||||
val roomType: RoomDetailsType,
|
||||
val roomMemberDetailsState: RoomMemberDetailsState?,
|
||||
val canEdit: Boolean,
|
||||
val canInvite: Boolean,
|
||||
val leaveRoomState: LeaveRoomState,
|
||||
val eventSink: (RoomDetailsEvent) -> Unit
|
||||
|
|
@ -40,3 +41,9 @@ sealed interface RoomDetailsType {
|
|||
object Room : RoomDetailsType
|
||||
data class Dm(val roomMember: RoomMember) : RoomDetailsType
|
||||
}
|
||||
|
||||
sealed interface RoomTopicState {
|
||||
object Hidden : RoomTopicState
|
||||
object CanAddTopic : RoomTopicState
|
||||
data class ExistingTopic(val topic: String) : RoomTopicState
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,13 +28,15 @@ open class RoomDetailsStateProvider : PreviewParameterProvider<RoomDetailsState>
|
|||
override val values: Sequence<RoomDetailsState>
|
||||
get() = sequenceOf(
|
||||
aRoomDetailsState(),
|
||||
aRoomDetailsState().copy(roomTopic = null),
|
||||
aRoomDetailsState().copy(roomTopic = RoomTopicState.Hidden),
|
||||
aRoomDetailsState().copy(roomTopic = RoomTopicState.CanAddTopic),
|
||||
aRoomDetailsState().copy(isEncrypted = false),
|
||||
aRoomDetailsState().copy(roomAlias = null),
|
||||
aRoomDetailsState().copy(memberCount = Async.Failure(Throwable())),
|
||||
aDmRoomDetailsState().copy(roomName = "Daniel"),
|
||||
aDmRoomDetailsState(isDmMemberIgnored = true).copy(roomName = "Daniel"),
|
||||
aRoomDetailsState().copy(canInvite = true),
|
||||
aRoomDetailsState().copy(canEdit = true),
|
||||
// Add other state here
|
||||
)
|
||||
}
|
||||
|
|
@ -64,14 +66,17 @@ fun aRoomDetailsState() = RoomDetailsState(
|
|||
roomName = "Marketing",
|
||||
roomAlias = "#marketing:domain.com",
|
||||
roomAvatarUrl = null,
|
||||
roomTopic = "Welcome to #marketing, home of the Marketing team " +
|
||||
"|| WIKI PAGE: https://domain.org/wiki/Marketing " +
|
||||
"|| MAIL iki/Marketing " +
|
||||
"|| MAI iki/Marketing " +
|
||||
"|| MAI iki/Marketing...",
|
||||
roomTopic = RoomTopicState.ExistingTopic(
|
||||
"Welcome to #marketing, home of the Marketing team " +
|
||||
"|| WIKI PAGE: https://domain.org/wiki/Marketing " +
|
||||
"|| MAIL iki/Marketing " +
|
||||
"|| MAI iki/Marketing " +
|
||||
"|| MAI iki/Marketing..."
|
||||
),
|
||||
memberCount = Async.Success(32),
|
||||
isEncrypted = true,
|
||||
canInvite = false,
|
||||
canEdit = false,
|
||||
roomType = RoomDetailsType.Room,
|
||||
roomMemberDetailsState = null,
|
||||
leaveRoomState = LeaveRoomState(),
|
||||
|
|
|
|||
|
|
@ -28,16 +28,25 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.outlined.Add
|
||||
import androidx.compose.material.icons.outlined.Lock
|
||||
import androidx.compose.material.icons.outlined.Person
|
||||
import androidx.compose.material.icons.outlined.PersonAddAlt
|
||||
import androidx.compose.material.icons.outlined.Share
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
|
@ -63,24 +72,26 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
|||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
import io.element.android.libraries.designsystem.preview.LargeHeightPreview
|
||||
import io.element.android.libraries.designsystem.theme.LocalColors
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.ui.strings.R as StringR
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun RoomDetailsView(
|
||||
state: RoomDetailsState,
|
||||
goBack: () -> Unit,
|
||||
onActionClicked: (RoomDetailsAction) -> Unit,
|
||||
onShareRoom: () -> Unit,
|
||||
onShareMember: (RoomMember) -> Unit,
|
||||
openRoomMemberList: () -> Unit,
|
||||
invitePeople: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
|
||||
fun onShareMember() {
|
||||
onShareMember((state.roomType as RoomDetailsType.Dm).roomMember)
|
||||
}
|
||||
|
|
@ -88,13 +99,18 @@ fun RoomDetailsView(
|
|||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(title = { }, navigationIcon = { BackButton(onClick = goBack) })
|
||||
RoomDetailsTopBar(
|
||||
goBack = goBack,
|
||||
showEdit = state.canEdit,
|
||||
onActionClicked = onActionClicked
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(modifier = Modifier
|
||||
.padding(padding)
|
||||
.consumeWindowInsets(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.consumeWindowInsets(padding)
|
||||
) {
|
||||
LeaveRoomView(state = state.leaveRoomState)
|
||||
|
||||
|
|
@ -108,6 +124,7 @@ fun RoomDetailsView(
|
|||
)
|
||||
MainActionsSection(onShareRoom = onShareRoom)
|
||||
}
|
||||
|
||||
is RoomDetailsType.Dm -> {
|
||||
val member = state.roomType.roomMember
|
||||
RoomMemberHeaderSection(
|
||||
|
|
@ -120,8 +137,11 @@ fun RoomDetailsView(
|
|||
}
|
||||
Spacer(Modifier.height(26.dp))
|
||||
|
||||
if (state.roomTopic != null) {
|
||||
TopicSection(roomTopic = state.roomTopic)
|
||||
if (state.roomTopic !is RoomTopicState.Hidden) {
|
||||
TopicSection(
|
||||
roomTopic = state.roomTopic,
|
||||
onActionClicked = onActionClicked,
|
||||
)
|
||||
}
|
||||
|
||||
if (state.roomType is RoomDetailsType.Room) {
|
||||
|
|
@ -129,10 +149,14 @@ fun RoomDetailsView(
|
|||
MembersSection(
|
||||
memberCount = memberCount,
|
||||
isLoading = state.memberCount.isLoading(),
|
||||
showInvite = state.canInvite,
|
||||
openRoomMemberList = openRoomMemberList,
|
||||
invitePeople = invitePeople,
|
||||
)
|
||||
|
||||
if (state.canInvite) {
|
||||
InviteSection(
|
||||
invitePeople = invitePeople
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.isEncrypted) {
|
||||
|
|
@ -152,6 +176,45 @@ fun RoomDetailsView(
|
|||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun RoomDetailsTopBar(
|
||||
goBack: () -> Unit,
|
||||
onActionClicked: (RoomDetailsAction) -> Unit,
|
||||
showEdit: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
|
||||
TopAppBar(
|
||||
modifier = modifier,
|
||||
title = { },
|
||||
navigationIcon = { BackButton(onClick = goBack) },
|
||||
actions = {
|
||||
if (showEdit) {
|
||||
IconButton(onClick = { showMenu = !showMenu }) {
|
||||
Icon(Icons.Default.MoreVert, "")
|
||||
}
|
||||
DropdownMenu(
|
||||
modifier = Modifier.widthIn(200.dp),
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(id = StringR.string.action_edit)) },
|
||||
onClick = {
|
||||
// Explicitly close the menu before handling the action, as otherwise it stays open during the
|
||||
// transition and renders really badly.
|
||||
showMenu = false
|
||||
onActionClicked(RoomDetailsAction.Edit)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun MainActionsSection(onShareRoom: () -> Unit, modifier: Modifier = Modifier) {
|
||||
Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
|
||||
|
|
@ -185,14 +248,26 @@ internal fun RoomHeaderSection(
|
|||
}
|
||||
|
||||
@Composable
|
||||
internal fun TopicSection(roomTopic: String, modifier: Modifier = Modifier) {
|
||||
internal fun TopicSection(
|
||||
roomTopic: RoomTopicState,
|
||||
onActionClicked: (RoomDetailsAction) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
PreferenceCategory(title = stringResource(StringR.string.common_topic), modifier = modifier) {
|
||||
Text(
|
||||
roomTopic,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 12.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
if (roomTopic is RoomTopicState.CanAddTopic) {
|
||||
PreferenceText(
|
||||
title = stringResource(R.string.screen_room_details_add_topic_title),
|
||||
icon = Icons.Outlined.Add,
|
||||
onClick = { onActionClicked(RoomDetailsAction.AddTopic) },
|
||||
)
|
||||
} else if (roomTopic is RoomTopicState.ExistingTopic) {
|
||||
Text(
|
||||
roomTopic.topic,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 12.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -200,8 +275,6 @@ internal fun TopicSection(roomTopic: String, modifier: Modifier = Modifier) {
|
|||
internal fun MembersSection(
|
||||
memberCount: Int?,
|
||||
isLoading: Boolean,
|
||||
showInvite: Boolean,
|
||||
invitePeople: () -> Unit,
|
||||
openRoomMemberList: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
|
|
@ -213,13 +286,20 @@ internal fun MembersSection(
|
|||
onClick = openRoomMemberList,
|
||||
loadingCurrentValue = isLoading,
|
||||
)
|
||||
if (showInvite) {
|
||||
PreferenceText(
|
||||
title = stringResource(R.string.screen_room_details_invite_people_title),
|
||||
icon = Icons.Outlined.PersonAddAlt,
|
||||
onClick = invitePeople,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun InviteSection(
|
||||
invitePeople: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
PreferenceCategory(modifier = modifier) {
|
||||
PreferenceText(
|
||||
title = stringResource(R.string.screen_room_details_invite_people_title),
|
||||
icon = Icons.Outlined.PersonAddAlt,
|
||||
onClick = invitePeople,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -261,6 +341,7 @@ private fun ContentToPreview(state: RoomDetailsState) {
|
|||
RoomDetailsView(
|
||||
state = state,
|
||||
goBack = {},
|
||||
onActionClicked = {},
|
||||
onShareRoom = {},
|
||||
onShareMember = {},
|
||||
openRoomMemberList = {},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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 = {}
|
||||
)
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -12,7 +12,10 @@
|
|||
<string name="screen_room_details_edition_error_title">"Unable to update room"</string>
|
||||
<string name="screen_room_details_encryption_enabled_subtitle">"Messages are secured with locks. Only you and the recipients have the unique keys to unlock them."</string>
|
||||
<string name="screen_room_details_encryption_enabled_title">"Message encryption enabled"</string>
|
||||
<string name="screen_room_details_room_name_label">"Room name"</string>
|
||||
<string name="screen_room_details_room_name_placeholder">"e.g. Product Sprint"</string>
|
||||
<string name="screen_room_details_share_room_title">"Share room"</string>
|
||||
<string name="screen_room_details_topic_placeholder">"What is this room about?"</string>
|
||||
<string name="screen_room_details_updating_room">"Updating room…"</string>
|
||||
<string name="screen_room_member_list_pending_header_title">"Pending"</string>
|
||||
<string name="screen_room_member_list_room_members_header_title">"Room members"</string>
|
||||
|
|
@ -22,7 +25,7 @@
|
|||
<string name="screen_dm_details_unblock_alert_action">"Unblock"</string>
|
||||
<string name="screen_dm_details_unblock_alert_description">"On unblocking the user, you will be able to see all messages by them again."</string>
|
||||
<string name="screen_dm_details_unblock_user">"Unblock user"</string>
|
||||
<string name="screen_room_details_invite_people_title">"Invite people"</string>
|
||||
<string name="screen_room_details_invite_people_title">"Invite friends to Element"</string>
|
||||
<string name="screen_room_details_leave_room_title">"Leave room"</string>
|
||||
<string name="screen_room_details_people_title">"People"</string>
|
||||
<string name="screen_room_details_security_title">"Security"</string>
|
||||
|
|
|
|||
|
|
@ -19,10 +19,11 @@ package io.element.android.features.roomdetails
|
|||
import app.cash.molecule.RecompositionClock
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.leaveroom.fake.LeaveRoomPresenterFake
|
||||
import io.element.android.features.roomdetails.impl.RoomDetailsPresenter
|
||||
import io.element.android.features.roomdetails.impl.RoomDetailsType
|
||||
import io.element.android.features.roomdetails.impl.RoomTopicState
|
||||
import io.element.android.features.roomdetails.impl.members.aRoomMember
|
||||
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
|
||||
import io.element.android.libraries.architecture.Async
|
||||
|
|
@ -31,8 +32,8 @@ import io.element.android.libraries.matrix.api.core.SessionId
|
|||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||
import io.element.android.libraries.matrix.api.room.StateEventType
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_NAME
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
|
|
@ -40,7 +41,6 @@ import io.element.android.libraries.matrix.test.A_USER_ID
|
|||
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
|
@ -48,9 +48,6 @@ import org.junit.Test
|
|||
@ExperimentalCoroutinesApi
|
||||
class RoomDetailsPresenterTests {
|
||||
|
||||
private val roomMembershipObserver = RoomMembershipObserver()
|
||||
private val testCoroutineDispatchers = testCoroutineDispatchers()
|
||||
|
||||
private fun aRoomDetailsPresenter(room: MatrixRoom): RoomDetailsPresenter {
|
||||
val roomMemberDetailsPresenterFactory = object : RoomMemberDetailsPresenter.Factory {
|
||||
override fun create(roomMemberId: UserId): RoomMemberDetailsPresenter {
|
||||
|
|
@ -68,12 +65,12 @@ class RoomDetailsPresenterTests {
|
|||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
Truth.assertThat(initialState.roomId).isEqualTo(room.roomId.value)
|
||||
Truth.assertThat(initialState.roomName).isEqualTo(room.name)
|
||||
Truth.assertThat(initialState.roomAvatarUrl).isEqualTo(room.avatarUrl)
|
||||
Truth.assertThat(initialState.roomTopic).isEqualTo(room.topic)
|
||||
Truth.assertThat(initialState.memberCount).isEqualTo(Async.Uninitialized)
|
||||
Truth.assertThat(initialState.isEncrypted).isEqualTo(room.isEncrypted)
|
||||
assertThat(initialState.roomId).isEqualTo(room.roomId.value)
|
||||
assertThat(initialState.roomName).isEqualTo(room.name)
|
||||
assertThat(initialState.roomAvatarUrl).isEqualTo(room.avatarUrl)
|
||||
assertThat(initialState.roomTopic).isEqualTo(RoomTopicState.ExistingTopic(room.topic!!))
|
||||
assertThat(initialState.memberCount).isEqualTo(Async.Uninitialized)
|
||||
assertThat(initialState.isEncrypted).isEqualTo(room.isEncrypted)
|
||||
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
|
|
@ -93,22 +90,22 @@ class RoomDetailsPresenterTests {
|
|||
}.test {
|
||||
room.givenRoomMembersState(MatrixRoomMembersState.Unknown)
|
||||
val initialState = awaitItem()
|
||||
Truth.assertThat(initialState.memberCount).isEqualTo(Async.Uninitialized)
|
||||
assertThat(initialState.memberCount).isEqualTo(Async.Uninitialized)
|
||||
skipItems(1)
|
||||
|
||||
room.givenRoomMembersState(MatrixRoomMembersState.Pending(null))
|
||||
val loadingState = awaitItem()
|
||||
Truth.assertThat(loadingState.memberCount).isEqualTo(Async.Loading(null))
|
||||
assertThat(loadingState.memberCount).isEqualTo(Async.Loading(null))
|
||||
|
||||
room.givenRoomMembersState(MatrixRoomMembersState.Error(error))
|
||||
skipItems(1)
|
||||
val failureState = awaitItem()
|
||||
Truth.assertThat(failureState.memberCount).isEqualTo(Async.Failure(error, null))
|
||||
assertThat(failureState.memberCount).isEqualTo(Async.Failure(error, null))
|
||||
|
||||
room.givenRoomMembersState(MatrixRoomMembersState.Ready(roomMembers))
|
||||
skipItems(1)
|
||||
val successState = awaitItem()
|
||||
Truth.assertThat(successState.memberCount).isEqualTo(Async.Success(1))
|
||||
assertThat(successState.memberCount).isEqualTo(Async.Success(1))
|
||||
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
|
|
@ -122,7 +119,7 @@ class RoomDetailsPresenterTests {
|
|||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
Truth.assertThat(initialState.roomName).isEqualTo(room.displayName)
|
||||
assertThat(initialState.roomName).isEqualTo(room.displayName)
|
||||
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
|
|
@ -144,7 +141,7 @@ class RoomDetailsPresenterTests {
|
|||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
Truth.assertThat(initialState.roomType).isEqualTo(RoomDetailsType.Dm(otherRoomMember))
|
||||
assertThat(initialState.roomType).isEqualTo(RoomDetailsType.Dm(otherRoomMember))
|
||||
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
|
|
@ -160,9 +157,9 @@ class RoomDetailsPresenterTests {
|
|||
presenter.present()
|
||||
}.test {
|
||||
// Initially false
|
||||
Truth.assertThat(awaitItem().canInvite).isFalse()
|
||||
assertThat(awaitItem().canInvite).isFalse()
|
||||
// Then the asynchronous check completes and it becomes true
|
||||
Truth.assertThat(awaitItem().canInvite).isTrue()
|
||||
assertThat(awaitItem().canInvite).isTrue()
|
||||
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
|
|
@ -177,7 +174,7 @@ class RoomDetailsPresenterTests {
|
|||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
Truth.assertThat(awaitItem().canInvite).isFalse()
|
||||
assertThat(awaitItem().canInvite).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -190,7 +187,103 @@ class RoomDetailsPresenterTests {
|
|||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
Truth.assertThat(awaitItem().canInvite).isFalse()
|
||||
assertThat(awaitItem().canInvite).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - initial state when user can edit one attribute`() = runTest {
|
||||
val room = aMatrixRoom().apply {
|
||||
givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.success(true))
|
||||
givenCanSendStateResult(StateEventType.ROOM_NAME, Result.success(false))
|
||||
givenCanSendStateResult(StateEventType.ROOM_AVATAR, Result.failure(Throwable("Whelp")))
|
||||
givenCanInviteResult(Result.success(false))
|
||||
}
|
||||
val presenter = aRoomDetailsPresenter(room)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
// Initially false
|
||||
assertThat(awaitItem().canEdit).isFalse()
|
||||
// Then the asynchronous check completes and it becomes true
|
||||
assertThat(awaitItem().canEdit).isTrue()
|
||||
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - initial state when user can edit all attributes`() = runTest {
|
||||
val room = aMatrixRoom().apply {
|
||||
givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.success(true))
|
||||
givenCanSendStateResult(StateEventType.ROOM_NAME, Result.success(true))
|
||||
givenCanSendStateResult(StateEventType.ROOM_AVATAR, Result.success(true))
|
||||
givenCanInviteResult(Result.success(false))
|
||||
}
|
||||
val presenter = aRoomDetailsPresenter(room)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
// Initially false
|
||||
assertThat(awaitItem().canEdit).isFalse()
|
||||
// Then the asynchronous check completes and it becomes true
|
||||
assertThat(awaitItem().canEdit).isTrue()
|
||||
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - initial state when user can edit no attributes`() = runTest {
|
||||
val room = aMatrixRoom().apply {
|
||||
givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.success(false))
|
||||
givenCanSendStateResult(StateEventType.ROOM_NAME, Result.success(false))
|
||||
givenCanSendStateResult(StateEventType.ROOM_AVATAR, Result.success(false))
|
||||
givenCanInviteResult(Result.success(false))
|
||||
}
|
||||
val presenter = aRoomDetailsPresenter(room)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
// Initially false, and no further events
|
||||
assertThat(awaitItem().canEdit).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - topic state is hidden when no topic and user has no permission`() = runTest {
|
||||
val room = aMatrixRoom(topic = null).apply {
|
||||
givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.success(false))
|
||||
givenCanInviteResult(Result.success(false))
|
||||
}
|
||||
|
||||
val presenter = aRoomDetailsPresenter(room)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
// The initial state is "hidden" and no further state changes happen
|
||||
assertThat(awaitItem().roomTopic).isEqualTo(RoomTopicState.Hidden)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - topic state is 'can add topic' when no topic and user has permission`() = runTest {
|
||||
val room = aMatrixRoom(topic = null).apply {
|
||||
givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.success(true))
|
||||
givenCanInviteResult(Result.success(false))
|
||||
}
|
||||
|
||||
val presenter = aRoomDetailsPresenter(room)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
// Ignore the initial state
|
||||
skipItems(1)
|
||||
|
||||
// When the async permission check finishes, the topic state will be updated
|
||||
assertThat(awaitItem().roomTopic).isEqualTo(RoomTopicState.CanAddTopic)
|
||||
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -79,3 +79,6 @@ val Compound_Gray_300_Light = Color(0xFFF0F2F5)
|
|||
val Compound_Gray_300_Dark = Color(0xFF1D1F24)
|
||||
val Compound_Gray_400_Light = Color(0xFFE1E6EC)
|
||||
val Compound_Gray_400_Dark = Color(0xFF26282D)
|
||||
|
||||
val Gray_1400_Light = Color(0xFF1B1D22)
|
||||
val Gray_1400_Dark = Color(0xFFEBEEF2)
|
||||
|
|
|
|||
|
|
@ -14,18 +14,17 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.createroom.impl.components
|
||||
package io.element.android.libraries.designsystem.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.features.createroom.impl.R
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
|
|
@ -36,8 +35,9 @@ fun LabelledTextField(
|
|||
label: String,
|
||||
value: String,
|
||||
modifier: Modifier = Modifier,
|
||||
placeholder: String = "",
|
||||
maxLines: Int = 1,
|
||||
placeholder: String? = null,
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
singleLine: Boolean = false,
|
||||
onValueChange: (String) -> Unit = {},
|
||||
) {
|
||||
Column(
|
||||
|
|
@ -46,15 +46,17 @@ fun LabelledTextField(
|
|||
) {
|
||||
Text(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
text = label
|
||||
)
|
||||
|
||||
TextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = value,
|
||||
placeholder = { Text(placeholder) },
|
||||
placeholder = placeholder?.let { { Text(placeholder) } },
|
||||
onValueChange = onValueChange,
|
||||
singleLine = maxLines == 1,
|
||||
singleLine = singleLine,
|
||||
maxLines = maxLines,
|
||||
)
|
||||
}
|
||||
|
|
@ -72,14 +74,14 @@ fun LabelledTextFieldDarkPreview() = ElementPreviewDark { ContentToPreview() }
|
|||
private fun ContentToPreview() {
|
||||
Column {
|
||||
LabelledTextField(
|
||||
label = stringResource(R.string.screen_create_room_room_name_label),
|
||||
label = "Room name",
|
||||
value = "",
|
||||
placeholder = stringResource(R.string.screen_create_room_room_name_placeholder),
|
||||
placeholder = "e.g. Product Sprint",
|
||||
)
|
||||
LabelledTextField(
|
||||
label = stringResource(R.string.screen_create_room_room_name_label),
|
||||
label = "Room name",
|
||||
value = "a room name",
|
||||
placeholder = stringResource(R.string.screen_create_room_room_name_placeholder),
|
||||
placeholder = "e.g. Product Sprint",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ import io.element.android.libraries.designsystem.Black_800
|
|||
import io.element.android.libraries.designsystem.Black_950
|
||||
import io.element.android.libraries.designsystem.Compound_Gray_300_Dark
|
||||
import io.element.android.libraries.designsystem.DarkGrey
|
||||
import io.element.android.libraries.designsystem.Gray_1400_Dark
|
||||
import io.element.android.libraries.designsystem.Gray_300
|
||||
import io.element.android.libraries.designsystem.Gray_400
|
||||
import io.element.android.libraries.designsystem.Compound_Gray_400_Dark
|
||||
|
|
@ -42,6 +43,7 @@ fun elementColorsDark() = ElementColors(
|
|||
quinary = Gray_450,
|
||||
gray300 = Compound_Gray_300_Dark,
|
||||
gray400 = Compound_Gray_400_Dark,
|
||||
gray1400 = Gray_1400_Dark,
|
||||
textActionCritical = TextColorCriticalDark,
|
||||
isLight = false,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -22,12 +22,13 @@ import androidx.compose.ui.graphics.Color
|
|||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import io.element.android.libraries.designsystem.Azure
|
||||
import io.element.android.libraries.designsystem.Black_900
|
||||
import io.element.android.libraries.designsystem.Compound_Gray_300_Light
|
||||
import io.element.android.libraries.designsystem.Compound_Gray_400_Light
|
||||
import io.element.android.libraries.designsystem.Gray_100
|
||||
import io.element.android.libraries.designsystem.Gray_1400_Light
|
||||
import io.element.android.libraries.designsystem.Gray_150
|
||||
import io.element.android.libraries.designsystem.Gray_200
|
||||
import io.element.android.libraries.designsystem.Gray_25
|
||||
import io.element.android.libraries.designsystem.Compound_Gray_300_Light
|
||||
import io.element.android.libraries.designsystem.Compound_Gray_400_Light
|
||||
import io.element.android.libraries.designsystem.Gray_50
|
||||
import io.element.android.libraries.designsystem.SystemGrey5Light
|
||||
import io.element.android.libraries.designsystem.SystemGrey6Light
|
||||
|
|
@ -42,6 +43,7 @@ fun elementColorsLight() = ElementColors(
|
|||
quinary = Gray_50,
|
||||
gray300 = Compound_Gray_300_Light,
|
||||
gray400 = Compound_Gray_400_Light,
|
||||
gray1400 = Gray_1400_Light,
|
||||
textActionCritical = TextColorCriticalLight,
|
||||
isLight = true,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ class ElementColors(
|
|||
quinary: Color,
|
||||
gray300: Color,
|
||||
gray400: Color,
|
||||
gray1400: Color,
|
||||
textActionCritical: Color,
|
||||
isLight: Boolean
|
||||
) {
|
||||
|
|
@ -53,6 +54,9 @@ class ElementColors(
|
|||
var gray400 by mutableStateOf(gray400)
|
||||
private set
|
||||
|
||||
var gray1400 by mutableStateOf(gray1400)
|
||||
private set
|
||||
|
||||
var textActionCritical by mutableStateOf(textActionCritical)
|
||||
private set
|
||||
|
||||
|
|
@ -67,6 +71,7 @@ class ElementColors(
|
|||
quinary: Color = this.quinary,
|
||||
gray300: Color = this.gray300,
|
||||
gray400: Color = this.gray400,
|
||||
gray1400: Color = this.gray1400,
|
||||
textActionCritical: Color = this.textActionCritical,
|
||||
isLight: Boolean = this.isLight,
|
||||
) = ElementColors(
|
||||
|
|
@ -77,6 +82,7 @@ class ElementColors(
|
|||
quinary = quinary,
|
||||
gray300 = gray300,
|
||||
gray400 = gray400,
|
||||
gray1400 = gray1400,
|
||||
textActionCritical = textActionCritical,
|
||||
isLight = isLight,
|
||||
)
|
||||
|
|
@ -89,6 +95,7 @@ class ElementColors(
|
|||
quinary = other.quinary
|
||||
gray300 = other.gray300
|
||||
gray400 = other.gray400
|
||||
gray1400 = other.gray1400
|
||||
textActionCritical = other.textActionCritical
|
||||
isLight = other.isLight
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,4 +89,14 @@ interface MatrixRoom : Closeable {
|
|||
suspend fun inviteUserById(id: UserId): Result<Unit>
|
||||
|
||||
suspend fun canInvite(): Result<Boolean>
|
||||
|
||||
suspend fun canSendStateEvent(type: StateEventType): Result<Boolean>
|
||||
|
||||
suspend fun updateAvatar(mimeType: String, data: ByteArray): Result<Unit>
|
||||
|
||||
suspend fun removeAvatar(): Result<Unit>
|
||||
|
||||
suspend fun setName(name: String): Result<Unit>
|
||||
|
||||
suspend fun setTopic(topic: String): Result<Unit>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -27,12 +27,14 @@ import io.element.android.libraries.matrix.api.media.ImageInfo
|
|||
import io.element.android.libraries.matrix.api.media.VideoInfo
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
||||
import io.element.android.libraries.matrix.api.room.StateEventType
|
||||
import io.element.android.libraries.matrix.api.room.roomMembers
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
||||
import io.element.android.libraries.matrix.impl.media.map
|
||||
import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline
|
||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
|
@ -224,6 +226,12 @@ class RustMatrixRoom(
|
|||
}
|
||||
}
|
||||
|
||||
override suspend fun canSendStateEvent(type: StateEventType): Result<Boolean> = withContext(coroutineDispatchers.io) {
|
||||
runCatching {
|
||||
innerRoom.member(sessionId.value).use { it.canSendState(type.map()) }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo): Result<Unit> = withContext(coroutineDispatchers.io) {
|
||||
runCatching {
|
||||
innerRoom.sendImage(file.path, thumbnailFile.path, imageInfo.map())
|
||||
|
|
@ -247,4 +255,33 @@ class RustMatrixRoom(
|
|||
innerRoom.sendFile(file.path, fileInfo.map())
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalUnsignedTypes::class)
|
||||
override suspend fun updateAvatar(mimeType: String, data: ByteArray): Result<Unit> =
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
innerRoom.uploadAvatar(mimeType, data.toUByteArray().toList())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun removeAvatar(): Result<Unit> =
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
innerRoom.removeAvatar()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setName(name: String): Result<Unit> =
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
innerRoom.setName(name)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setTopic(topic: String): Result<Unit> =
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
innerRoom.setTopic(topic)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@ import io.element.android.libraries.matrix.api.media.ImageInfo
|
|||
import io.element.android.libraries.matrix.api.media.VideoInfo
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
||||
import io.element.android.libraries.matrix.api.room.StateEventType
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
|
|
@ -62,7 +63,13 @@ class FakeMatrixRoom(
|
|||
private var rejectInviteResult = Result.success(Unit)
|
||||
private var inviteUserResult = Result.success(Unit)
|
||||
private var canInviteResult = Result.success(true)
|
||||
private val canSendStateResults = mutableMapOf<StateEventType, Result<Boolean>>()
|
||||
private var sendMediaResult = Result.success(Unit)
|
||||
private var setNameResult = Result.success(Unit)
|
||||
private var setTopicResult = Result.success(Unit)
|
||||
private var updateAvatarResult = Result.success(Unit)
|
||||
private var removeAvatarResult = Result.success(Unit)
|
||||
|
||||
var sendMediaCount = 0
|
||||
private set
|
||||
|
||||
|
|
@ -75,6 +82,18 @@ class FakeMatrixRoom(
|
|||
var invitedUserId: UserId? = null
|
||||
private set
|
||||
|
||||
var newTopic: String? = null
|
||||
private set
|
||||
|
||||
var newName: String? = null
|
||||
private set
|
||||
|
||||
var newAvatarData: ByteArray? = null
|
||||
private set
|
||||
|
||||
var removedAvatar: Boolean = false
|
||||
private set
|
||||
|
||||
private var leaveRoomError: Throwable? = null
|
||||
|
||||
override val membersStateFlow: MutableStateFlow<MatrixRoomMembersState> = MutableStateFlow(MatrixRoomMembersState.Unknown)
|
||||
|
|
@ -151,6 +170,10 @@ class FakeMatrixRoom(
|
|||
return canInviteResult
|
||||
}
|
||||
|
||||
override suspend fun canSendStateEvent(type: StateEventType): Result<Boolean> {
|
||||
return canSendStateResults[type] ?: Result.failure(IllegalStateException("No fake answer"))
|
||||
}
|
||||
|
||||
override suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo): Result<Unit> = fakeSendMedia()
|
||||
|
||||
override suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo): Result<Unit> = fakeSendMedia()
|
||||
|
|
@ -166,6 +189,26 @@ class FakeMatrixRoom(
|
|||
}
|
||||
}
|
||||
|
||||
override suspend fun updateAvatar(mimeType: String, data: ByteArray): Result<Unit> {
|
||||
newAvatarData = data
|
||||
return updateAvatarResult
|
||||
}
|
||||
|
||||
override suspend fun removeAvatar(): Result<Unit> {
|
||||
removedAvatar = true
|
||||
return removeAvatarResult
|
||||
}
|
||||
|
||||
override suspend fun setName(name: String): Result<Unit> {
|
||||
newName = name
|
||||
return setNameResult
|
||||
}
|
||||
|
||||
override suspend fun setTopic(topic: String): Result<Unit> {
|
||||
newTopic = topic
|
||||
return setTopicResult
|
||||
}
|
||||
|
||||
override fun close() = Unit
|
||||
|
||||
fun givenLeaveRoomError(throwable: Throwable?) {
|
||||
|
|
@ -204,6 +247,10 @@ class FakeMatrixRoom(
|
|||
canInviteResult = result
|
||||
}
|
||||
|
||||
fun givenCanSendStateResult(type: StateEventType, result: Result<Boolean>) {
|
||||
canSendStateResults[type] = result
|
||||
}
|
||||
|
||||
fun givenIgnoreResult(result: Result<Unit>) {
|
||||
ignoreResult = result
|
||||
}
|
||||
|
|
@ -215,4 +262,20 @@ class FakeMatrixRoom(
|
|||
fun givenSendMediaResult(result: Result<Unit>) {
|
||||
sendMediaResult = result
|
||||
}
|
||||
|
||||
fun givenUpdateAvatarResult(result: Result<Unit>) {
|
||||
updateAvatarResult = result
|
||||
}
|
||||
|
||||
fun givenRemoveAvatarResult(result: Result<Unit>) {
|
||||
removeAvatarResult = result
|
||||
}
|
||||
|
||||
fun givenSetNameResult(result: Result<Unit>) {
|
||||
setNameResult = result
|
||||
}
|
||||
|
||||
fun givenSetTopicResult(result: Result<Unit>) {
|
||||
setTopicResult = result
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
|
||||
@file:OptIn(ExperimentalMaterialApi::class)
|
||||
|
||||
package io.element.android.features.createroom.impl.configureroom.avatar
|
||||
package io.element.android.libraries.matrix.ui.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
|
|
@ -39,12 +39,13 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
|||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheetLayout
|
||||
import io.element.android.libraries.matrix.ui.media.AvatarAction
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun AvatarActionListView(
|
||||
fun AvatarActionBottomSheet(
|
||||
actions: ImmutableList<AvatarAction>,
|
||||
modalBottomSheetState: ModalBottomSheetState,
|
||||
modifier: Modifier = Modifier,
|
||||
|
|
@ -62,7 +63,7 @@ fun AvatarActionListView(
|
|||
modifier = modifier,
|
||||
sheetState = modalBottomSheetState,
|
||||
sheetContent = {
|
||||
SheetContent(
|
||||
AvatarActionBottomSheetContent(
|
||||
actions = actions,
|
||||
onActionClicked = ::onItemActionClicked,
|
||||
modifier = Modifier
|
||||
|
|
@ -74,7 +75,7 @@ fun AvatarActionListView(
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun SheetContent(
|
||||
private fun AvatarActionBottomSheetContent(
|
||||
actions: ImmutableList<AvatarAction>,
|
||||
modifier: Modifier = Modifier,
|
||||
onActionClicked: (AvatarAction) -> Unit = { },
|
||||
|
|
@ -107,17 +108,17 @@ private fun SheetContent(
|
|||
|
||||
@Preview
|
||||
@Composable
|
||||
fun SheetContentLightPreview() =
|
||||
fun AvatarActionBottomSheetLightPreview() =
|
||||
ElementPreviewLight { ContentToPreview() }
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun SheetContentDarkPreview() =
|
||||
fun AvatarActionBottomSheetDarkPreview() =
|
||||
ElementPreviewDark { ContentToPreview() }
|
||||
|
||||
@Composable
|
||||
private fun ContentToPreview() {
|
||||
AvatarActionListView(
|
||||
AvatarActionBottomSheet(
|
||||
actions = persistentListOf(AvatarAction.TakePhoto, AvatarAction.ChoosePhoto, AvatarAction.Remove),
|
||||
modalBottomSheetState = ModalBottomSheetState(
|
||||
initialValue = ModalBottomSheetValue.Expanded
|
||||
|
|
@ -14,11 +14,10 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.createroom.impl.components
|
||||
package io.element.android.libraries.matrix.ui.components
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.size
|
||||
|
|
@ -43,16 +42,19 @@ import io.element.android.libraries.designsystem.preview.debugPlaceholderBackgro
|
|||
import io.element.android.libraries.designsystem.theme.LocalColors
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
|
||||
/**
|
||||
* An avatar that the user has selected, but which has not yet been uploaded to Matrix.
|
||||
*
|
||||
* The image is loaded from a local resource instead of from a MXC URI.
|
||||
*/
|
||||
@Composable
|
||||
fun Avatar(
|
||||
fun UnsavedAvatar(
|
||||
avatarUri: Uri?,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit = {},
|
||||
) {
|
||||
val commonModifier = modifier
|
||||
.size(70.dp)
|
||||
.clip(CircleShape)
|
||||
.clickable(onClick = onClick)
|
||||
|
||||
if (avatarUri != null) {
|
||||
val context = LocalContext.current
|
||||
|
|
@ -82,16 +84,16 @@ fun Avatar(
|
|||
|
||||
@Preview
|
||||
@Composable
|
||||
fun AvatarLightPreview() = ElementPreviewLight { ContentToPreview() }
|
||||
fun UnsavedAvatarLightPreview() = ElementPreviewLight { ContentToPreview() }
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun AvatarDarkPreview() = ElementPreviewDark { ContentToPreview() }
|
||||
fun UnsavedAvatarDarkPreview() = ElementPreviewDark { ContentToPreview() }
|
||||
|
||||
@Composable
|
||||
private fun ContentToPreview() {
|
||||
Row {
|
||||
Avatar(null)
|
||||
Avatar(Uri.EMPTY)
|
||||
UnsavedAvatar(null)
|
||||
UnsavedAvatar(Uri.EMPTY)
|
||||
}
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.createroom.impl.configureroom.avatar
|
||||
package io.element.android.libraries.matrix.ui.media
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.material.icons.Icons
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:55d774d5e61f8832162859ec9d22299efa5b0728cfc3308b13fb11df31c5130e
|
||||
size 13790
|
||||
oid sha256:20e9edff7567936627718427349a2ca7bf318a909b6ca206304deab0149a796d
|
||||
size 13934
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b96cf938377ee0f02e0cf2d5896eb83935d9416a5b87849ed81caed4db5e90f2
|
||||
size 41264
|
||||
oid sha256:ab9f1e15c5a071be9c2f63aa28e6fbd57c360960899f808cead275ef67a54ae6
|
||||
size 41444
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2ed229820d7cbd6b550e98116ec3bf688c52957e2a57ab296bfc5abd212345b1
|
||||
size 13784
|
||||
oid sha256:0b2350c880e73ce285bfeafafc851c6973e0372330ab6f4daa16d078ed6a81c9
|
||||
size 13884
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:92fe30f0927b8be4ffc384548e6731e7d5e347dc5caa1c84251f2a932ee1873c
|
||||
size 38848
|
||||
oid sha256:71d10fb1a2102466ab759442a1e006a3f5f85cb80f9f885cb5d18c8ded570bef
|
||||
size 38945
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:fb3c154cf9fac2bc17b0ac3575bd2ce7745781cc1f2b9f28c220f98fb359c74d
|
||||
size 16284
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1d932221affde9bf53b06ea0e2b9c4b45dffe6adad7ff92f75775d9fe1d15b77
|
||||
size 16024
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a9da3afa82783ded1e686a36eb969b8e097de853e9c6dadea24884f7975c0720
|
||||
size 63729
|
||||
oid sha256:533ccd5b4e27b98ae4155e70d8cdde8a3c263ca613cc3e0ce31c2fc1d52758b8
|
||||
size 63361
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e2c200874d6fa8f4fe07da69a7879eb264fb8941c1ab9654162cb76586c52936
|
||||
size 103326
|
||||
oid sha256:7a036b609372a8c2f20307ca9f13b619973da8da45e81a019af1fdafadda64de
|
||||
size 102275
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e48bb979a1d328871157847862723b8342cd8c2704750045bbf24592e143a5e0
|
||||
size 57874
|
||||
oid sha256:4032529e222fb9d14d4dc1fd215c8aae5d63326809df7cec449011d7904a60d2
|
||||
size 58355
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4320fd1900a120a737ba2a56e6c1f55cf671eb0e8c1791dc605994d333feb0c2
|
||||
size 96888
|
||||
oid sha256:2bf49968f5b07ca6798c4c8670a9e173ef3a511c6c60ed8d55d652bb042b8a8b
|
||||
size 96370
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6f36c2b4f4266048d5295df08f380e4128630d11cce7ac11cf3a0eaaa5594d61
|
||||
size 19924
|
||||
oid sha256:4fddc08d41da556719424efbc75a1a80a57202095ce082124bc0bca08e54c74b
|
||||
size 21415
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:23b9e996b8f0cc2efcb3adb6294bfa7b4a53fefbb7b6ee07add4105da9b9d40e
|
||||
size 18984
|
||||
oid sha256:2639e6662a1c78ff0f6091fb72b5fc06b486aa08e3beed78e4877b9282ce98d6
|
||||
size 20309
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ec2051ec47f43a88aeb0d56078094fca784c948ce2b3e1a450343943ab7ee951
|
||||
size 35408
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:85d54c4c049c16435c4e6bcc8bf6d25dbc3fccc90a0278c2c330c97334eb27d0
|
||||
size 28119
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6caa0bdf8897ab6d8d73b8dfeac5c8c3cece5945e87123a1f5a1c96de477e623
|
||||
size 32262
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:df9d8b28c6e98632204e90bccfddcf1e0d0ca6ce1a19af03f5bc491ade32fa36
|
||||
size 34793
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:bfed133c28a48fb90aeda89de0154b4ab5c209d08a75147065dd8cc126f955eb
|
||||
size 34416
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d3e1f45de5ab8d0bd953bd2a0a0014b7dce3315a82d993ceb633304f2b8c2622
|
||||
size 28517
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:02d7335bab9fd2bd753086ceeed886e57e0800b4d36a2cacd136c37bd41ebb9d
|
||||
size 27152
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c61f9803c3313f6e2cceb966942bec7885fcb2efd87e94babdac8e37b95d4da5
|
||||
size 35380
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a1a6d6b7fb6f2d97d63773dae499ed096dbf38d16cd34cafcee3b46571717bcb
|
||||
size 27828
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:31d763b42ffc618bbf1c3272cbd4484edd7ab687f0ba25391955edf8f57c3208
|
||||
size 32443
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ca9b7a692c9d5bfa0ab0789f1d33a2f66c6244b1f4b672fab1aa18a71b3c9d0a
|
||||
size 33897
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:bdbeb867e5e009206a55eb1dfb0c478499301baa317a979cb2ffe9b1198871e8
|
||||
size 33931
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:dcf0472ccdfd1256eca966c6d29b4aace86f9ddab37164bb865633349a4377f3
|
||||
size 28519
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3733e12a63cd72d529f9359f555cda4da65384bd7631f8450451b27095691193
|
||||
size 26945
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:516e972069292f31625847ca39de06ea402ce1606c97d84f930c18eaa2448cfa
|
||||
size 13759
|
||||
oid sha256:9ba1f70268339a290e35e2127d1a3fa03840e69dc143631ef2fe29f22ddf646a
|
||||
size 15363
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b387f10903926ba9ed30b7d213314388eebf23743cbbdbcf4b613cf3136f64aa
|
||||
size 41524
|
||||
oid sha256:01b7d6a33e3591a54bcf602bac34155b85056ebdf600c511858efba55ea43cf4
|
||||
size 43034
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0af8c7149fd4266c68c0e4b8b9a699bc0b3fc0b05fea3f2e2544abc884f70c5b
|
||||
size 11988
|
||||
oid sha256:cdb5e42bc94ca908c76b5da491ed712169876e8f2ea80534f2e722b7280ba72d
|
||||
size 13602
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8eb2fcdb57b7f1ba28990f9576fad6dce156fe0c5c9ae491797040fc22ae4f7b
|
||||
size 39516
|
||||
oid sha256:656cec478d23350ed5c6e08b6cd9579e497999d6a7a8aa879af7dd223fefa98b
|
||||
size 41088
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1e4d3bc53e6a86957218247b614be6bbc78cfb35ce49565f2243f50d6ef94fe3
|
||||
size 14216
|
||||
oid sha256:456b9c09e0ff314543fa6a0e54d7e6e41f346e3fd2ff33f9ba1ea3ae3168d629
|
||||
size 15839
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:213777e4ace6f9c33209db37bffff2ca6dba13e540e9d07be82239d261ee8e3f
|
||||
size 63951
|
||||
oid sha256:d76042bbb94307f96dc99d5077b58204b8f30a195408739271321570c218d0da
|
||||
size 65441
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:cb1178f063eafb21ba01b93310d84335afbc4e6ad1fc99d1776578fe3f7eaa07
|
||||
size 49237
|
||||
oid sha256:22bda941040d3ad60d709517f07a27933ae25a5a69464e492fe1d0f9c4d7c405
|
||||
size 50488
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8ddec11fd636d345c49cf981ce7498ac2d94027d4b223b257b64288bb0254766
|
||||
size 13328
|
||||
oid sha256:bcc75c47150e577714221b5d574a67c69051cf2f4439f8b7c929620509d87562
|
||||
size 14668
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:da5ba4637d999c8654e38a86ad0b26c75a6fe10a6988eb821b24cf91da2274e5
|
||||
size 39070
|
||||
oid sha256:1df29f2bd4090162dcbf7336971c366b53b49390f5f41cc6df6c6f506f01ab30
|
||||
size 40464
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c57c20fe79d0dbb0ca25571fa2ab3ba6e06f3a9c6634e96fac3ed0155c2ea106
|
||||
size 11160
|
||||
oid sha256:d488d43188f491cc578d30a70d05c90d6c436ca8fad88a36b85a1ef5bc90e88b
|
||||
size 12564
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8e43279132a7536516cf267599f877f7c729f76be51d6319944f74c57af0e282
|
||||
size 36493
|
||||
oid sha256:900065642b4eb9a90a3a5d1bbbb5f03e9057b08e5e8289e535fd9237ac15e497
|
||||
size 37824
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:933c5ac4478699e9e713af559025ab675a8bdc93b15c42ff27fe5525fa3b668a
|
||||
size 13166
|
||||
oid sha256:b457fe4bc8ff8a26612819abbbac6440b539e61a8f00fb4171ad38dfb131840a
|
||||
size 14553
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3a66a39bf18ba35abfc87dea22ca019eea98727bf246142cb034649c5b1772bd
|
||||
size 61041
|
||||
oid sha256:8384acb8fef843e0f06108d230c40bda5db7a95932568d37cbd610eaf09acc23
|
||||
size 62392
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ca9a896e2dd3cf47792467f052f2d45b555d99ccbd91436ef4c03dfca66836ab
|
||||
size 47542
|
||||
oid sha256:307bfb8ffa9088d7d474c0d51e5e8d54bd6598b527357fe67380c755c2af75f0
|
||||
size 48893
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c3e225aea9018c660e16ce5ee6e1e343b439b96264b274c5d722b05c6c9a5d48
|
||||
size 52064
|
||||
oid sha256:53015a9106ad2c3a24b9073e142db6fb3ba0e40ef3e87b1ba2142ec04066eba6
|
||||
size 53644
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2372fe63e9fcc2c6529622270592437b880211acdcf7ef8be36dfc9fe41e0e5b
|
||||
size 66807
|
||||
oid sha256:c3e225aea9018c660e16ce5ee6e1e343b439b96264b274c5d722b05c6c9a5d48
|
||||
size 52064
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:dce5d8c8dacb7c095cd22d590fc4fd9bc2cb9b13e0d4edbdd52387226b37b4ec
|
||||
size 70253
|
||||
oid sha256:2372fe63e9fcc2c6529622270592437b880211acdcf7ef8be36dfc9fe41e0e5b
|
||||
size 66807
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d3a1afc10771257294d9f22d26ed8a81d04cd4b1f31d9fb9fc1065a8216230a9
|
||||
size 67658
|
||||
oid sha256:dce5d8c8dacb7c095cd22d590fc4fd9bc2cb9b13e0d4edbdd52387226b37b4ec
|
||||
size 70253
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7a3b23895b133b7bba7300fbf31fd70daa83f558e29e8c09dfc7789ce0bc04d6
|
||||
size 61861
|
||||
oid sha256:d3a1afc10771257294d9f22d26ed8a81d04cd4b1f31d9fb9fc1065a8216230a9
|
||||
size 67658
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8c3317c3c03399d5575a089029e237c766b912bb80a96a8feccb005f4e7247f5
|
||||
size 63166
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f3dd0fc6e0024160c03749948e4d4c6b353e3b5162b6ecc219c8bd16c2ad9848
|
||||
size 71157
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b22c207ac5f185c1c7eaa30ffe43acb2a1e8da06379f15f66ff96797b8ea9158
|
||||
size 48297
|
||||
oid sha256:7045a9ced4691ec52451b6a31d2454ddc82c04b767c96a3dd4c3f81addcb8f0c
|
||||
size 50010
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a7a98c0125cc58a130b8ebfbd5f2b8fcfa65e02b0c6a151c0b594208a8801c96
|
||||
size 60894
|
||||
oid sha256:b22c207ac5f185c1c7eaa30ffe43acb2a1e8da06379f15f66ff96797b8ea9158
|
||||
size 48297
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:998819b0aeb15712bb53e982e3756cf2e4a4069a2a13f7955e10b873e3e736bd
|
||||
size 63698
|
||||
oid sha256:a7a98c0125cc58a130b8ebfbd5f2b8fcfa65e02b0c6a151c0b594208a8801c96
|
||||
size 60894
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:cb8dc0ca3fa34ffa40ea43934ee00d0c48c9b7ce721274763c093eee83d8cff9
|
||||
size 62187
|
||||
oid sha256:998819b0aeb15712bb53e982e3756cf2e4a4069a2a13f7955e10b873e3e736bd
|
||||
size 63698
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e403d076b9d28e7fd06493399d4b7c405d99f329fb1a298c41fb445a1e9ec466
|
||||
size 57215
|
||||
oid sha256:cb8dc0ca3fa34ffa40ea43934ee00d0c48c9b7ce721274763c093eee83d8cff9
|
||||
size 62187
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:da8ee4d42e3fcc4b68e06406bee859634c245baeb9ab7850427fffc04f41f1f8
|
||||
size 58493
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1797f9c90a79fc9bcbb1aa8d8ae32452c76d17b12e8ef7f441e1c93e9ac95c3f
|
||||
size 64593
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7cc0a54fec11686b8ffcba432562cfc1115973fab77693f8983a5b81a64e5d78
|
||||
size 15683
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:738fd87b80036a040ceaddbad06ed4657a0d8d9d8c6052933300e5c194c17f24
|
||||
size 15724
|
||||
Loading…
Add table
Add a link
Reference in a new issue