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