Add ability to forward a message to several rooms. (#7013)

* Forward message to multiple rooms

* Ensure event is sent only once per room.

* `RoomSelectEvents` -> `RoomSelectEvent`

* Use test extension on presenter.

* Room selection: add a limitation of 10 rooms for Share and Forward actions.

* Update screenshots

* Fix padding issue

* Update screenshots

* `RadioButton` -> `Checkbox`

* Update screenshots

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Benoit Marty
2026-06-17 13:57:44 +02:00
committed by GitHub
23 changed files with 150 additions and 104 deletions
@@ -74,7 +74,10 @@ class ForwardMessagesNode(
return roomSelectEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
params = RoomSelectEntryPoint.Params(mode = RoomSelectMode.Forward),
params = RoomSelectEntryPoint.Params(
mode = RoomSelectMode.Forward,
maxNumberOfRooms = RoomSelectEntryPoint.DEFAULT_MAX_NUMBER_OF_ROOMS,
),
callback = callback,
)
}
@@ -70,7 +70,10 @@ class ShareNode(
return roomSelectEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
params = RoomSelectEntryPoint.Params(mode = RoomSelectMode.Share),
params = RoomSelectEntryPoint.Params(
mode = RoomSelectMode.Share,
maxNumberOfRooms = RoomSelectEntryPoint.DEFAULT_MAX_NUMBER_OF_ROOMS,
),
callback = callback,
)
}
@@ -49,7 +49,7 @@ class RoomContentForwarder(
val content = (messageLikeContent.kind as? MsgLikeKind.Message)?.content
?: throw ForwardEventException(toRoomIds)
val targetRooms = toRoomIds.mapNotNull { roomId -> roomListService.roomOrNull(roomId.value) }
val targetRooms = toRoomIds.toSet().mapNotNull { roomId -> roomListService.roomOrNull(roomId.value) }
val failedForwardingTo = mutableSetOf<RoomId>()
targetRooms.parallelMap { room ->
room.use { targetRoom ->
@@ -17,6 +17,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
interface RoomSelectEntryPoint : FeatureEntryPoint {
data class Params(
val mode: RoomSelectMode,
val maxNumberOfRooms: Int,
)
fun createNode(
@@ -30,4 +31,8 @@ interface RoomSelectEntryPoint : FeatureEntryPoint {
fun onRoomSelected(roomIds: List<RoomId>)
fun onCancel()
}
companion object {
const val DEFAULT_MAX_NUMBER_OF_ROOMS = 10
}
}
@@ -26,7 +26,10 @@ class DefaultRoomSelectEntryPoint : RoomSelectEntryPoint {
return parentNode.createNode<RoomSelectNode>(
buildContext = buildContext,
plugins = listOf(
RoomSelectNode.Inputs(mode = params.mode),
RoomSelectNode.Inputs(
mode = params.mode,
maxNumberOfRooms = params.maxNumberOfRooms,
),
callback,
)
)
@@ -10,11 +10,8 @@ package io.element.android.libraries.roomselect.impl
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
sealed interface RoomSelectEvents {
data class SetSelectedRoom(val room: SelectRoomInfo) : RoomSelectEvents
// TODO remove to restore multi-selection
data object RemoveSelectedRoom : RoomSelectEvents
data object ToggleSearchActive : RoomSelectEvents
data class UpdateVisibleRange(val range: IntRange) : RoomSelectEvents
sealed interface RoomSelectEvent {
data class ToggleSelectedRoom(val room: SelectRoomInfo) : RoomSelectEvent
data object ToggleSearchActive : RoomSelectEvent
data class UpdateVisibleRange(val range: IntRange) : RoomSelectEvent
}
@@ -35,10 +35,14 @@ class RoomSelectNode(
) : Node(buildContext, plugins = plugins) {
data class Inputs(
val mode: RoomSelectMode,
val maxNumberOfRooms: Int,
) : NodeInputs
private val inputs: Inputs = inputs()
private val presenter = presenterFactory.create(inputs.mode)
private val presenter = presenterFactory.create(
mode = inputs.mode,
maxNumberOfRooms = inputs.maxNumberOfRooms,
)
private val stateFlow = launchMolecule { presenter.present() }
private val callback: RoomSelectEntryPoint.Callback = callback()
@@ -34,11 +34,15 @@ import kotlinx.coroutines.launch
@AssistedInject
class RoomSelectPresenter(
@Assisted private val mode: RoomSelectMode,
@Assisted private val maxNumberOfRooms: Int,
private val dataSourceFactory: RoomSelectSearchDataSource.Factory,
) : Presenter<RoomSelectState> {
@AssistedFactory
fun interface Factory {
fun create(mode: RoomSelectMode): RoomSelectPresenter
fun create(
mode: RoomSelectMode,
maxNumberOfRooms: Int,
): RoomSelectPresenter
}
@Composable
@@ -67,21 +71,18 @@ class RoomSelectPresenter(
}
}
fun handleEvent(event: RoomSelectEvents) {
fun handleEvent(event: RoomSelectEvent) {
when (event) {
is RoomSelectEvents.SetSelectedRoom -> {
selectedRooms = persistentListOf(event.room)
// Restore for multi-selection
// val index = selectedRooms.indexOfFirst { it.roomId == event.room.roomId }
// selectedRooms = if (index >= 0) {
// selectedRooms.removeAt(index)
// } else {
// selectedRooms.add(event.room)
// }
is RoomSelectEvent.ToggleSelectedRoom -> {
val index = selectedRooms.indexOfFirst { it.roomId == event.room.roomId }
selectedRooms = if (index >= 0) {
selectedRooms.removeAt(index)
} else {
selectedRooms.add(event.room)
}
}
RoomSelectEvents.RemoveSelectedRoom -> selectedRooms = persistentListOf()
RoomSelectEvents.ToggleSearchActive -> isSearchActive = !isSearchActive
is RoomSelectEvents.UpdateVisibleRange -> coroutineScope.launch {
RoomSelectEvent.ToggleSearchActive -> isSearchActive = !isSearchActive
is RoomSelectEvent.UpdateVisibleRange -> coroutineScope.launch {
dataSource.updateVisibleRange(event.range)
}
}
@@ -89,6 +90,7 @@ class RoomSelectPresenter(
return RoomSelectState(
mode = mode,
maxNumberOfRooms = maxNumberOfRooms,
resultState = searchResults,
searchQuery = queryState,
isSearchActive = isSearchActive,
@@ -16,9 +16,12 @@ import kotlinx.collections.immutable.ImmutableList
data class RoomSelectState(
val mode: RoomSelectMode,
val maxNumberOfRooms: Int,
val resultState: SearchBarResultState<ImmutableList<SelectRoomInfo>>,
val searchQuery: TextFieldState,
val isSearchActive: Boolean,
val selectedRooms: ImmutableList<SelectRoomInfo>,
val eventSink: (RoomSelectEvents) -> Unit
)
val eventSink: (RoomSelectEvent) -> Unit,
) {
val canSelectMoreRooms = selectedRooms.size < maxNumberOfRooms
}
@@ -40,18 +40,26 @@ open class RoomSelectStateProvider : PreviewParameterProvider<RoomSelectState> {
mode = RoomSelectMode.Share,
resultState = SearchBarResultState.Results(aRoomSelectRoomList()),
),
aRoomSelectState(
mode = RoomSelectMode.Share,
resultState = SearchBarResultState.Results(aRoomSelectRoomList()),
selectedRooms = aRoomSelectRoomList().subList(0, 1),
maxNumberOfRooms = 1,
),
)
}
internal fun aRoomSelectState(
mode: RoomSelectMode = RoomSelectMode.Forward,
maxNumberOfRooms: Int = 10,
resultState: SearchBarResultState<ImmutableList<SelectRoomInfo>> = SearchBarResultState.Initial(),
searchQuery: String = "",
isSearchActive: Boolean = false,
selectedRooms: ImmutableList<SelectRoomInfo> = persistentListOf(),
eventSink: (RoomSelectEvents) -> Unit = {},
eventSink: (RoomSelectEvent) -> Unit = {},
) = RoomSelectState(
mode = mode,
maxNumberOfRooms = maxNumberOfRooms,
resultState = resultState,
searchQuery = TextFieldState(initialText = searchQuery),
isSearchActive = isSearchActive,
@@ -44,8 +44,8 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Checkbox
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.RadioButton
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.SearchBarResultState
@@ -71,26 +71,30 @@ fun RoomSelectView(
onSubmit: (List<RoomId>) -> Unit,
modifier: Modifier = Modifier,
) {
@Suppress("UNUSED_PARAMETER")
fun onRoomRemoved(roomInfo: SelectRoomInfo) {
// TODO toggle selection when multi-selection is enabled
state.eventSink(RoomSelectEvents.RemoveSelectedRoom)
state.eventSink(RoomSelectEvent.ToggleSelectedRoom(roomInfo))
}
@Composable
fun SelectedRoomsHelper(isForwarding: Boolean, selectedRooms: ImmutableList<SelectRoomInfo>) {
if (isForwarding) return
SelectedRooms(
selectedRooms = selectedRooms,
onRemoveRoom = ::onRoomRemoved,
modifier = Modifier.padding(vertical = 16.dp)
)
fun SelectedRoomsHelper(
selectedRooms: ImmutableList<SelectRoomInfo>,
showVerticalSpace: Boolean,
) {
if (selectedRooms.isNotEmpty()) {
SelectedRooms(
selectedRooms = selectedRooms,
onRemoveRoom = ::onRoomRemoved,
modifier = Modifier.padding(vertical = 16.dp)
)
} else if (showVerticalSpace) {
Spacer(modifier = Modifier.height(20.dp))
}
}
var canHandleBack by remember { mutableStateOf(true) }
fun onBackButton(state: RoomSelectState) {
if (state.isSearchActive) {
state.eventSink(RoomSelectEvents.ToggleSearchActive)
state.eventSink(RoomSelectEvent.ToggleSearchActive)
} else if (canHandleBack) {
canHandleBack = false
onDismiss()
@@ -104,7 +108,7 @@ fun RoomSelectView(
val lazyListState = rememberLazyListState()
OnVisibleRangeChangeEffect(lazyListState) { visibleRange ->
state.eventSink(RoomSelectEvents.UpdateVisibleRange(visibleRange))
state.eventSink(RoomSelectEvent.UpdateVisibleRange(visibleRange))
}
Scaffold(
@@ -141,16 +145,15 @@ fun RoomSelectView(
placeHolderTitle = stringResource(CommonStrings.action_search),
queryState = state.searchQuery,
active = state.isSearchActive,
onActiveChange = { state.eventSink(RoomSelectEvents.ToggleSearchActive) },
onActiveChange = { state.eventSink(RoomSelectEvent.ToggleSearchActive) },
resultState = state.resultState,
showBackButton = false,
) { summaries ->
LazyColumn(state = lazyListState) {
item {
SelectedRoomsHelper(
// TODO state.isForwarding
isForwarding = false,
selectedRooms = state.selectedRooms
selectedRooms = state.selectedRooms,
showVerticalSpace = false,
)
}
items(summaries, key = { it.roomId.value }) { roomSummary ->
@@ -159,8 +162,9 @@ fun RoomSelectView(
roomSummary,
isSelected = state.selectedRooms.any { it.roomId == roomSummary.roomId },
onSelection = { roomSummary ->
state.eventSink(RoomSelectEvents.SetSelectedRoom(roomSummary))
}
state.eventSink(RoomSelectEvent.ToggleSelectedRoom(roomSummary))
},
canBeSelected = state.canSelectMoreRooms,
)
HorizontalDivider(modifier = Modifier.fillMaxWidth())
}
@@ -169,13 +173,10 @@ fun RoomSelectView(
}
if (!state.isSearchActive) {
// TODO restore for multi-selection
// SelectedRoomsHelper(
// isForwarding = state.isForwarding,
// selectedRooms = state.selectedRooms
// )
Spacer(modifier = Modifier.height(20.dp))
SelectedRoomsHelper(
selectedRooms = state.selectedRooms,
showVerticalSpace = true,
)
if (state.resultState is SearchBarResultState.Results) {
LazyColumn(state = lazyListState) {
items(state.resultState.results, key = { it.roomId.value }) { roomSummary ->
@@ -184,8 +185,9 @@ fun RoomSelectView(
roomSummary,
isSelected = state.selectedRooms.any { it.roomId == roomSummary.roomId },
onSelection = { roomSummary ->
state.eventSink(RoomSelectEvents.SetSelectedRoom(roomSummary))
}
state.eventSink(RoomSelectEvent.ToggleSelectedRoom(roomSummary))
},
canBeSelected = state.canSelectMoreRooms,
)
HorizontalDivider(modifier = Modifier.fillMaxWidth())
}
@@ -218,6 +220,7 @@ private fun SelectedRooms(
private fun RoomSummaryView(
roomInfo: SelectRoomInfo,
isSelected: Boolean,
canBeSelected: Boolean,
onSelection: (SelectRoomInfo) -> Unit,
) {
Row(
@@ -262,7 +265,11 @@ private fun RoomSummaryView(
)
}
}
RadioButton(selected = isSelected, onClick = { onSelection(roomInfo) })
Checkbox(
checked = isSelected,
enabled = isSelected || canBeSelected,
onCheckedChange = { onSelection(roomInfo) },
)
}
}
@@ -33,13 +33,15 @@ class DefaultRoomSelectEntryPointTest {
fun `test node builder`() = runTest {
val entryPoint = DefaultRoomSelectEntryPoint()
val testMode = RoomSelectMode.Share
val testMaxNumberOfRooms = 12
val parentNode = TestParentNode.create { buildContext, plugins ->
RoomSelectNode(
buildContext = buildContext,
plugins = plugins,
presenterFactory = { mode ->
presenterFactory = { mode, maxNumberOfRooms ->
assertThat(mode).isEqualTo(testMode)
createRoomSelectPresenter(mode)
assertThat(maxNumberOfRooms).isEqualTo(testMaxNumberOfRooms)
createRoomSelectPresenter(mode, maxNumberOfRooms)
},
)
}
@@ -47,7 +49,10 @@ class DefaultRoomSelectEntryPointTest {
override fun onRoomSelected(roomIds: List<RoomId>) = lambdaError()
override fun onCancel() = lambdaError()
}
val params = RoomSelectEntryPoint.Params(testMode)
val params = RoomSelectEntryPoint.Params(
mode = testMode,
maxNumberOfRooms = testMaxNumberOfRooms,
)
val result = entryPoint.createNode(
parentNode = parentNode,
buildContext = BuildContext.root(null),
@@ -55,7 +60,7 @@ class DefaultRoomSelectEntryPointTest {
callback = callback,
)
assertThat(result).isInstanceOf(RoomSelectNode::class.java)
assertThat(result.plugins).contains(RoomSelectNode.Inputs(params.mode))
assertThat(result.plugins).contains(RoomSelectNode.Inputs(params.mode, params.maxNumberOfRooms))
assertThat(result.plugins).contains(callback)
}
}
@@ -9,9 +9,6 @@
package io.element.android.libraries.roomselect.impl
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
@@ -20,10 +17,12 @@ import io.element.android.libraries.matrix.test.room.aRoomSummary
import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.ui.model.toSelectRoomInfo
import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint
import io.element.android.libraries.roomselect.api.RoomSelectMode
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope
@@ -40,26 +39,24 @@ class RoomSelectPresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = createRoomSelectPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
val initialState = awaitItem()
assertThat(initialState.selectedRooms).isEmpty()
assertThat(initialState.resultState).isInstanceOf(SearchBarResultState.Initial::class.java)
assertThat(initialState.isSearchActive).isFalse()
assertThat(initialState.maxNumberOfRooms).isEqualTo(10)
assertThat(initialState.canSelectMoreRooms).isTrue()
}
}
@Test
fun `present - toggle search active`() = runTest {
val presenter = createRoomSelectPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
val initialState = awaitItem()
initialState.eventSink(RoomSelectEvents.ToggleSearchActive)
initialState.eventSink(RoomSelectEvent.ToggleSearchActive)
assertThat(awaitItem().isSearchActive).isTrue()
initialState.eventSink(RoomSelectEvents.ToggleSearchActive)
initialState.eventSink(RoomSelectEvent.ToggleSearchActive)
assertThat(awaitItem().isSearchActive).isFalse()
}
}
@@ -76,15 +73,13 @@ class RoomSelectPresenterTest {
val presenter = createRoomSelectPresenter(
roomListService = roomListService
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
val initialState = awaitItem()
val expectedRoomInfo = roomSummary.toSelectRoomInfo()
// Do not compare the lambda because they will be different. So copy the lambda from expectedRoomSummary to result
val result = (awaitItem().resultState as SearchBarResultState.Results).results
assertThat(result).isEqualTo(listOf(expectedRoomInfo))
initialState.eventSink(RoomSelectEvents.ToggleSearchActive)
initialState.eventSink(RoomSelectEvent.ToggleSearchActive)
skipItems(1)
initialState.searchQuery.setTextAndPlaceCursorAtEnd("string not contained")
assertThat(
@@ -110,17 +105,22 @@ class RoomSelectPresenterTest {
createRoomListLambda = { roomList }
)
val presenter = createRoomSelectPresenter(
maxNumberOfRooms = 1,
roomListService = roomListService,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
val initialState = awaitItem()
val roomInfo = roomSummary.toSelectRoomInfo()
initialState.eventSink(RoomSelectEvents.SetSelectedRoom(roomInfo))
assertThat(awaitItem().selectedRooms).isEqualTo(persistentListOf(roomInfo))
initialState.eventSink(RoomSelectEvents.RemoveSelectedRoom)
assertThat(awaitItem().selectedRooms).isEmpty()
initialState.eventSink(RoomSelectEvent.ToggleSelectedRoom(roomInfo))
awaitItem().let {
assertThat(it.selectedRooms).isEqualTo(persistentListOf(roomInfo))
assertThat(it.canSelectMoreRooms).isFalse()
it.eventSink(RoomSelectEvent.ToggleSelectedRoom(roomInfo))
}
awaitItem().let {
assertThat(it.selectedRooms).isEmpty()
assertThat(it.canSelectMoreRooms).isTrue()
}
cancel()
}
}
@@ -136,9 +136,7 @@ class RoomSelectPresenterTest {
createRoomListLambda = { roomList }
)
val presenter = createRoomSelectPresenter(roomListService = roomListService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
val initialState = awaitItem()
// Post some rooms to simulate loaded content
val rooms = (1..10).map { aRoomSummary() }
@@ -146,7 +144,7 @@ class RoomSelectPresenterTest {
skipItems(1)
// UpdateVisibleRange near end should trigger loadMore
initialState.eventSink(RoomSelectEvents.UpdateVisibleRange(IntRange(0, 9)))
initialState.eventSink(RoomSelectEvent.UpdateVisibleRange(IntRange(0, 9)))
// Give time for the coroutine to complete
testScheduler.advanceUntilIdle()
@@ -157,9 +155,11 @@ class RoomSelectPresenterTest {
internal fun TestScope.createRoomSelectPresenter(
mode: RoomSelectMode = RoomSelectMode.Forward,
maxNumberOfRooms: Int = RoomSelectEntryPoint.DEFAULT_MAX_NUMBER_OF_ROOMS,
roomListService: RoomListService = FakeRoomListService(),
) = RoomSelectPresenter(
mode = mode,
maxNumberOfRooms = maxNumberOfRooms,
dataSourceFactory = object : RoomSelectSearchDataSource.Factory {
override fun create(coroutineScope: CoroutineScope): RoomSelectSearchDataSource {
return RoomSelectSearchDataSource(
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d06e84c695086a799c8377bcf48869675b98dcadd1b5320e685d775589ac40c7
size 30950
oid sha256:2a93b747f763fc0613c3fa5fdb492be034426916a4b3761a61f4c1d5fc655440
size 28772
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3825e3c312d75a865545745ee00b64d8c2259e515dc45fad64236963e91f2427
size 28999
oid sha256:feaf2f316024a21d49c7b71b1e3df892e60924a715fc892871ee1f06006145ab
size 26657
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7b0f7e2b97a343d0e1bedd9595a1057a03a4429c3fa72613451ac86a7146392a
size 33382
oid sha256:19f984bbf1f12dc3774b24c4fbadaae40c024cc876c8bf172af29a22429b43e0
size 31248
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cccff3215bc81e2a6491f3b0da3f4d8997179b54c698f962eb245f535e6cc9fc
size 28655
oid sha256:c3ff0ef8dc9adc1378bef8a87479aed5a4cc1d43050851f516e9de17ef7201ee
size 26450
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b1dd54a38725d9fdc035a73aebfbe9447377970e1b9e33dd512f0d41e2a11ec1
size 30975
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b38450dc7cc24e6f9a0db829aa11058d10ae45d433945afd534627534f362554
size 30261
oid sha256:c3545c6c2b173b70bfba5dd571ea23db31ca7c2e64554131ba5cdbbb0555ea35
size 28229
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c1b36608e269c7572c93c6f10cddeaf42bfeba404c6699a6e050144bc28bc55e
size 28569
oid sha256:947bb1138d11180f6174db8b9fadf51cec8d8d2947f0f47db148b479fa1f4d86
size 26340
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:96f198461d8d60d8610f337c3dbe90bebd8c98f32a4739310532d629ca336a1f
size 33113
oid sha256:e035e0c89cf000340d17257a9ad0e3e2ea1937f7408664742d9c4ba0c5b8c2c3
size 31149
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:12350783e27c09074bdaba2e74ac445b643ff6a13251c13a7cdf98999274325d
size 27915
oid sha256:f0159ba2e298d8c3c7663ed0fdb4dca4ae179e00d40e5fdec8223ca597adbe6a
size 25871
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:42ff2caf57dc21ea2681423082d91592091ad062ba350e91a7bbd10500768d58
size 30515