Merge pull request #211 from vector-im/feature/fre/start_chat_search_matrixid

[Start chat] Show a single result when searching for a matrixId
This commit is contained in:
Florian Renaud 2023-03-21 13:58:24 +01:00 committed by GitHub
commit 62a65f7507
26 changed files with 246 additions and 64 deletions

1
changelog.d/211.wip Normal file
View file

@ -0,0 +1 @@
[Create and join rooms] Show a single result when searching for a matrixId

View file

@ -16,7 +16,12 @@
package io.element.android.features.createroom.impl.root
import io.element.android.libraries.matrix.ui.model.MatrixUser
sealed interface CreateRoomRootEvents {
data class UpdateSearchQuery(val query: String) : CreateRoomRootEvents
data class StartDM(val matrixUser: MatrixUser) : CreateRoomRootEvents
object CreateRoom : CreateRoomRootEvents
object InvitePeople : CreateRoomRootEvents
data class OnSearchActiveChanged(val active: Boolean) : CreateRoomRootEvents
}

View file

@ -17,23 +17,76 @@
package io.element.android.features.createroom.impl.root
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.core.MatrixPatterns
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import timber.log.Timber
import javax.inject.Inject
class CreateRoomRootPresenter @Inject constructor() : Presenter<CreateRoomRootState> {
@Composable
override fun present(): CreateRoomRootState {
var isSearchActive by rememberSaveable { mutableStateOf(false) }
var searchQuery by rememberSaveable { mutableStateOf("") }
val searchResults: MutableState<ImmutableList<MatrixUser>> = remember {
mutableStateOf(persistentListOf())
}
fun handleEvents(event: CreateRoomRootEvents) {
when (event) {
is CreateRoomRootEvents.OnSearchActiveChanged -> isSearchActive = event.active
is CreateRoomRootEvents.UpdateSearchQuery -> searchQuery = event.query
is CreateRoomRootEvents.StartDM -> handleStartDM(event.matrixUser)
CreateRoomRootEvents.CreateRoom -> Unit // Todo Handle create room action
CreateRoomRootEvents.InvitePeople -> Unit // Todo Handle invite people action
}
}
LaunchedEffect(searchQuery) {
// Clear the search results before performing the search, manually add a fake result with the matrixId, if any
searchResults.value = if (MatrixPatterns.isUserId(searchQuery)) {
persistentListOf(MatrixUser(UserId(searchQuery)))
} else {
persistentListOf()
}
// Perform the search asynchronously
if (searchQuery.isNotEmpty()) {
searchResults.value = performSearch(searchQuery)
}
}
return CreateRoomRootState(
eventSink = ::handleEvents
eventSink = ::handleEvents,
isSearchActive = isSearchActive,
searchQuery = searchQuery,
searchResults = searchResults.value,
)
}
private fun performSearch(query: String): ImmutableList<MatrixUser> {
val isMatrixId = MatrixPatterns.isUserId(query)
val results = mutableListOf<MatrixUser>()// TODO trigger /search request
if (isMatrixId && results.none { it.id.value == query }) {
val getProfileResult: MatrixUser? = null // TODO trigger /profile request
val profile = getProfileResult ?: MatrixUser(UserId(query))
results.add(0, profile)
}
return results.toImmutableList()
}
private fun handleStartDM(matrixUser: MatrixUser) {
Timber.d("handleStartDM: $matrixUser") // Todo handle start DM action
}
}

View file

@ -16,8 +16,13 @@
package io.element.android.features.createroom.impl.root
// TODO add your ui models. Remove the eventSink if you don't have events.
import io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.collections.immutable.ImmutableList
// Do not use default value, so no member get forgotten in the presenters.
data class CreateRoomRootState(
val eventSink: (CreateRoomRootEvents) -> Unit
val eventSink: (CreateRoomRootEvents) -> Unit,
val isSearchActive: Boolean,
val searchQuery: String,
val searchResults: ImmutableList<MatrixUser>,
)

View file

@ -17,15 +17,43 @@
package io.element.android.features.createroom.impl.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.collections.immutable.persistentListOf
open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRootState> {
override val values: Sequence<CreateRoomRootState>
get() = sequenceOf(
aCreateRoomRootState(),
// Add other state here
aCreateRoomRootState().copy(isSearchActive = true),
aCreateRoomRootState().copy(isSearchActive = true, searchQuery = "someone"),
aCreateRoomRootState().copy(
isSearchActive = true,
searchQuery = "@someone:matrix.org",
searchResults = persistentListOf(
MatrixUser(id = UserId("@someone:matrix.org")),
MatrixUser(id = UserId("@someone:matrix.org"), username = "someone"),
MatrixUser(
id = UserId("@someone_with_a_very_long_matrix_identifier:a_very_long_domain.org"),
username = "hey, I am someone with a very long display name"
),
MatrixUser(id = UserId("@someone_2:matrix.org"), username = "someone 2"),
MatrixUser(id = UserId("@someone_3:matrix.org"), username = "someone 3"),
MatrixUser(id = UserId("@someone_4:matrix.org"), username = "someone 4"),
MatrixUser(id = UserId("@someone_5:matrix.org"), username = "someone 5"),
MatrixUser(id = UserId("@someone_6:matrix.org"), username = "someone 6"),
MatrixUser(id = UserId("@someone_7:matrix.org"), username = "someone 7"),
MatrixUser(id = UserId("@someone_8:matrix.org"), username = "someone 8"),
MatrixUser(id = UserId("@someone_9:matrix.org"), username = "someone 9"),
MatrixUser(id = UserId("@someone_10:matrix.org"), username = "someone 10"),
)
),
)
}
fun aCreateRoomRootState() = CreateRoomRootState(
eventSink = {}
eventSink = {},
isSearchActive = false,
searchQuery = "",
searchResults = persistentListOf(),
)

View file

@ -22,18 +22,16 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
@ -45,15 +43,19 @@ 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.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.CenterAlignedTopAppBar
import io.element.android.libraries.designsystem.theme.components.DockedSearchBar
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.SearchBar
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
import io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.collections.immutable.ImmutableList
import io.element.android.libraries.designsystem.R as DrawableR
import io.element.android.libraries.ui.strings.R as StringR
@ -64,12 +66,10 @@ fun CreateRoomRootView(
modifier: Modifier = Modifier,
onClosePressed: () -> Unit = {},
) {
var searchText by rememberSaveable { mutableStateOf("") }
var isSearchActive by rememberSaveable { mutableStateOf(false) }
Scaffold(
modifier = modifier.fillMaxWidth(),
topBar = {
if (!isSearchActive) {
if (!state.isSearchActive) {
CreateRoomRootViewTopBar(onClosePressed = onClosePressed)
}
}
@ -80,14 +80,16 @@ fun CreateRoomRootView(
) {
CreateRoomSearchBar(
modifier = Modifier.fillMaxWidth(),
text = searchText,
query = state.searchQuery,
placeHolderTitle = stringResource(StringR.string.search_for_someone),
active = isSearchActive,
onActiveChanged = { isSearchActive = it },
onTextChanged = { searchText = it },
results = state.searchResults,
active = state.isSearchActive,
onActiveChanged = { state.eventSink(CreateRoomRootEvents.OnSearchActiveChanged(it)) },
onTextChanged = { state.eventSink(CreateRoomRootEvents.UpdateSearchQuery(it)) },
onResultSelected = { state.eventSink(CreateRoomRootEvents.StartDM(it)) }
)
if (!isSearchActive) {
if (!state.isSearchActive) {
CreateRoomActionButtonsList(
onNewRoomClicked = { state.eventSink(CreateRoomRootEvents.CreateRoom) },
onInvitePeopleClicked = { state.eventSink(CreateRoomRootEvents.InvitePeople) },
@ -123,12 +125,14 @@ fun CreateRoomRootViewTopBar(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CreateRoomSearchBar(
text: String,
query: String,
placeHolderTitle: String,
results: ImmutableList<MatrixUser>,
active: Boolean,
modifier: Modifier = Modifier,
onActiveChanged: (Boolean) -> Unit = {},
onTextChanged: (String) -> Unit = {},
onResultSelected: (MatrixUser) -> Unit = {},
) {
val focusManager = LocalFocusManager.current
@ -137,8 +141,8 @@ fun CreateRoomSearchBar(
focusManager.clearFocus()
}
DockedSearchBar(
query = text,
SearchBar(
query = query,
onQueryChange = onTextChanged,
onSearch = { focusManager.clearFocus() },
active = active,
@ -153,9 +157,11 @@ fun CreateRoomSearchBar(
},
leadingIcon = if (active) {
{ BackButton(onClick = { onActiveChanged(false) }) }
} else null,
} else {
null
},
trailingIcon = when {
active && text.isNotEmpty() -> {
active && query.isNotEmpty() -> {
{
IconButton(onClick = { onTextChanged("") }) {
Icon(Icons.Default.Close, stringResource(StringR.string.a11y_clear))
@ -173,9 +179,17 @@ fun CreateRoomSearchBar(
}
else -> null
},
shape = if (!active) SearchBarDefaults.dockedShape else SearchBarDefaults.fullScreenShape,
colors = if (!active) SearchBarDefaults.colors() else SearchBarDefaults.colors(containerColor = Color.Transparent),
content = {},
content = {
LazyColumn {
items(results) {
CreateRoomSearchResultItem(
matrixUser = it,
onClick = { onResultSelected(it) }
)
}
}
},
)
}
@ -199,6 +213,20 @@ fun CreateRoomActionButtonsList(
}
}
@Composable
fun CreateRoomSearchResultItem(
matrixUser: MatrixUser,
modifier: Modifier = Modifier,
onClick: () -> Unit = {},
) {
MatrixUserRow(
modifier = modifier,
matrixUser = matrixUser,
avatarSize = AvatarSize.Custom(36.dp),
onClick = onClick,
)
}
@Composable
fun CreateRoomActionButton(
@DrawableRes iconRes: Int,
@ -209,7 +237,7 @@ fun CreateRoomActionButton(
Row(
modifier = modifier
.fillMaxWidth()
.heightIn(min = 56.dp)
.height(56.dp)
.clickable { onClick() }
.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),

View file

@ -22,8 +22,8 @@ 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.features.createroom.impl.root.CreateRoomRootEvents
import io.element.android.features.createroom.impl.root.CreateRoomRootPresenter
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
@ -42,7 +42,7 @@ class CreateRoomRootPresenterTests {
}
@Test
fun `present - send event`() = runTest {
fun `present - trigger action buttons`() = runTest {
val presenter = CreateRoomRootPresenter()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -52,4 +52,42 @@ class CreateRoomRootPresenterTests {
initialState.eventSink(CreateRoomRootEvents.InvitePeople) // Not implemented yet
}
}
@Test
fun `present - update search query`() = runTest {
val presenter = CreateRoomRootPresenter()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(CreateRoomRootEvents.OnSearchActiveChanged(true))
assertThat(awaitItem().isSearchActive).isTrue()
val matrixIdQuery = "@name:matrix.org"
initialState.eventSink(CreateRoomRootEvents.UpdateSearchQuery(matrixIdQuery))
assertThat(awaitItem().searchQuery).isEqualTo(matrixIdQuery)
assertThat(awaitItem().searchResults).containsExactly(MatrixUser(UserId(matrixIdQuery)))
val notMatrixIdQuery = "name"
initialState.eventSink(CreateRoomRootEvents.UpdateSearchQuery(notMatrixIdQuery))
assertThat(awaitItem().searchQuery).isEqualTo(notMatrixIdQuery)
assertThat(awaitItem().searchResults).isEmpty()
initialState.eventSink(CreateRoomRootEvents.OnSearchActiveChanged(false))
assertThat(awaitItem().isSearchActive).isFalse()
}
}
@Test
fun `present - trigger start DM action`() = runTest {
val presenter = CreateRoomRootPresenter()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val matrixUser = MatrixUser(UserId("@name:matrix.org"))
initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
}
}
}

View file

@ -94,7 +94,7 @@ private fun InitialsAvatar(
Text(
modifier = Modifier.align(Alignment.Center),
text = avatarData.getInitial(),
fontSize = (avatarData.size.value / 2).sp,
fontSize = (avatarData.size.dp / 2).value.sp,
color = Color.White,
)
}

View file

@ -16,13 +16,16 @@
package io.element.android.libraries.designsystem.components.avatar
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
enum class AvatarSize(val value: Int) {
SMALL(32),
MEDIUM(40),
BIG(48),
HUGE(96);
sealed class AvatarSize(open val dp: Dp) {
val dp = value.dp
object SMALL : AvatarSize(32.dp)
object MEDIUM : AvatarSize(40.dp)
object BIG : AvatarSize(48.dp)
object HUGE : AvatarSize(96.dp)
// FIXME maybe remove this field and switch back to an enum (or remove this class) when design system will be integrated
data class Custom(override val dp: Dp) : AvatarSize(dp)
}

View file

@ -20,6 +20,7 @@ package io.element.android.libraries.designsystem.theme.components
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SearchBarColors
import androidx.compose.material3.SearchBarDefaults
@ -34,7 +35,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewLight
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DockedSearchBar(
fun SearchBar(
query: String,
onQueryChange: (String) -> Unit,
onSearch: (String) -> Unit,
@ -45,13 +46,14 @@ fun DockedSearchBar(
placeholder: @Composable (() -> Unit)? = null,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
shape: Shape = SearchBarDefaults.dockedShape,
shape: Shape = SearchBarDefaults.inputFieldShape,
colors: SearchBarColors = SearchBarDefaults.colors(),
tonalElevation: Dp = SearchBarDefaults.Elevation,
windowInsets: WindowInsets = SearchBarDefaults.windowInsets,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
content: @Composable ColumnScope.() -> Unit,
) {
androidx.compose.material3.DockedSearchBar(
androidx.compose.material3.SearchBar(
query = query,
onQueryChange = onQueryChange,
onSearch = onSearch,
@ -65,6 +67,7 @@ fun DockedSearchBar(
shape = shape,
colors = colors,
tonalElevation = tonalElevation,
windowInsets = windowInsets,
interactionSource = interactionSource,
content = content,
)
@ -80,7 +83,7 @@ internal fun DockedSearchBarDarkPreview() = ElementPreviewDark { ContentToPrevie
@Composable
private fun ContentToPreview() {
DockedSearchBar(
SearchBar(
query = "Some text",
onQueryChange = {},
onSearch = {},

View file

@ -34,35 +34,34 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.ui.model.MatrixUser
import io.element.android.libraries.matrix.ui.model.getBestName
// FIXME Row are not the same height if there is a name or not.
@Composable
fun MatrixUserRow(
matrixUser: MatrixUser,
modifier: Modifier = Modifier,
avatarSize: AvatarSize = matrixUser.avatarData.size,
onClick: () -> Unit = {},
) {
Row(
modifier = modifier
.clickable(onClick = onClick)
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(start = 16.dp, top = 8.dp, end = 16.dp, bottom = 8.dp)
.height(IntrinsicSize.Min),
verticalAlignment = Alignment.CenterVertically
) {
Avatar(
matrixUser.avatarData,
matrixUser.avatarData.copy(size = avatarSize),
)
Column(
modifier = Modifier
.padding(start = 12.dp, end = 4.dp, top = 12.dp, bottom = 12.dp)
.alignByBaseline()
.weight(1f)
.padding(start = 12.dp),
) {
// Name
Text(

View file

@ -18,7 +18,8 @@ package io.element.android.libraries.matrix.ui.media
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.media.MediaResolver
import kotlin.math.roundToInt
fun AvatarData.toMetadata(): MediaResolver.Meta {
return MediaResolver.Meta(url = url, kind = MediaResolver.Kind.Thumbnail(size.value))
return MediaResolver.Meta(url = url, kind = MediaResolver.Kind.Thumbnail(size.dp.value.roundToInt()))
}

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5451d8fb06cef9a346c92db43fa5994e39f3079b67b99d867a31d9b8a645cb0d
size 19198
oid sha256:0161ce815927b9a7f21060d94a9e98cc67b832c945d7437848b118731aace5c4
size 19221

View file

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

View file

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

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:611b1efa9cf46316f96738130f326061595d2648ac7fb059df299e7c95f4e2a8
size 18480
oid sha256:07e26a6494155ad18acc5da1a9151ffb02910185a3d0a516884848d7b045e640
size 18434

View file

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

View file

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

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e89807d72107f1f8337e8de803791249b56bde6c4961805520195ea55563749a
size 8470
oid sha256:5ba2ec897834fa0db2751c2c8a7b4427e278cf4711bed3fecf75937c080d42ba
size 8534

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6381c125b8eaf6b116218bc4b0efda4866f19514139f4b45604909e462931c1f
size 8320
oid sha256:75923126de5b769aa04de9214af14e3aec23e91b85f5a4c8d57c21025850ec1b
size 8358

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:73594765454707aa2894d86432639edac0b59adaf554b0fe2fb7fc7e9976c4f8
size 14445
oid sha256:df97ab1093fd2c257f98b16fefeb28cc4a31747a7a9bda0725fa826a26c59906
size 14299

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5e6ceaf1f7f7d8cd59327d7b88ba2136468de76542e84504a6ef689f0e58d896
size 13695
oid sha256:3b3649fa2543fd138246ac3281f491f2a0a1c9f38c977e57a840cd560038af2f
size 13782

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3ce4773eed490ebeb3ed46d534181afb90331ba1cbaab8ead7b192739beb29a6
size 13681
oid sha256:38556398b29c5230751f86b35b9e0b233c1ef4a84d1aa37928cc4d10a8062c9b
size 13567

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c8a4a16c343f295a5270bb746024578dea8f3015ffe35a57b0ed667727d976e2
size 13182
oid sha256:e7a0cf84208247fe5a5a118a8e8133d79b875859a65788139b7c4459c575c5d8
size 13226