Merge pull request #467 from vector-im/feature/bma/reworkGrouper

Improve timeline rendering for message and state event
This commit is contained in:
Benoit Marty 2023-05-30 12:39:25 +02:00 committed by GitHub
commit 7c8eef33eb
91 changed files with 431 additions and 264 deletions

View file

@ -153,11 +153,6 @@ fun MessagesView(
} }
} }
fun onExpandGroupClick(event: TimelineItem.GroupedEvents) {
Timber.v("onExpandGroupClick= ${event.id}")
state.timelineState.eventSink(TimelineEvents.ToggleExpandGroup(event))
}
fun onActionSelected(action: TimelineItemAction, event: TimelineItem.Event) { fun onActionSelected(action: TimelineItemAction, event: TimelineItem.Event) {
state.eventSink(MessagesEvents.HandleAction(action, event)) state.eventSink(MessagesEvents.HandleAction(action, event))
} }
@ -208,7 +203,6 @@ fun MessagesView(
.consumeWindowInsets(padding), .consumeWindowInsets(padding),
onMessageClicked = ::onMessageClicked, onMessageClicked = ::onMessageClicked,
onMessageLongClicked = ::onMessageLongClicked, onMessageLongClicked = ::onMessageLongClicked,
onExpandGroupClick = ::onExpandGroupClick,
) )
}, },
snackbarHost = { snackbarHost = {
@ -247,7 +241,6 @@ fun MessagesViewContent(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onMessageClicked: (TimelineItem.Event) -> Unit = {}, onMessageClicked: (TimelineItem.Event) -> Unit = {},
onMessageLongClicked: (TimelineItem.Event) -> Unit = {}, onMessageLongClicked: (TimelineItem.Event) -> Unit = {},
onExpandGroupClick: (TimelineItem.GroupedEvents) -> Unit = {},
) { ) {
Column( Column(
modifier = modifier modifier = modifier
@ -262,7 +255,6 @@ fun MessagesViewContent(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
onMessageClicked = onMessageClicked, onMessageClicked = onMessageClicked,
onMessageLongClicked = onMessageLongClicked, onMessageLongClicked = onMessageLongClicked,
onExpandGroupClick = onExpandGroupClick,
) )
} }
MessageComposerView( MessageComposerView(

View file

@ -16,11 +16,9 @@
package io.element.android.features.messages.impl.timeline package io.element.android.features.messages.impl.timeline
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
sealed interface TimelineEvents { sealed interface TimelineEvents {
object LoadMore : TimelineEvents object LoadMore : TimelineEvents
data class SetHighlightedEvent(val eventId: EventId?) : TimelineEvents data class SetHighlightedEvent(val eventId: EventId?) : TimelineEvents
data class ToggleExpandGroup(val event: TimelineItem.GroupedEvents) : TimelineEvents
} }

View file

@ -20,19 +20,14 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
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.timeline.MatrixTimeline import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@ -45,7 +40,6 @@ private const val backPaginationPageSize = 50
class TimelinePresenter @Inject constructor( class TimelinePresenter @Inject constructor(
private val timelineItemsFactory: TimelineItemsFactory, private val timelineItemsFactory: TimelineItemsFactory,
private val timelineItemGrouper: TimelineItemGrouper,
room: MatrixRoom, room: MatrixRoom,
) : Presenter<TimelineState> { ) : Presenter<TimelineState> {
@ -57,7 +51,6 @@ class TimelinePresenter @Inject constructor(
val highlightedEventId: MutableState<EventId?> = rememberSaveable { val highlightedEventId: MutableState<EventId?> = rememberSaveable {
mutableStateOf(null) mutableStateOf(null)
} }
val expandedGroups = remember { mutableStateMapOf<String, Boolean>() }
val timelineItems = timelineItemsFactory val timelineItems = timelineItemsFactory
.flow() .flow()
@ -71,9 +64,6 @@ class TimelinePresenter @Inject constructor(
when (event) { when (event) {
TimelineEvents.LoadMore -> localCoroutineScope.loadMore(paginationState.value) TimelineEvents.LoadMore -> localCoroutineScope.loadMore(paginationState.value)
is TimelineEvents.SetHighlightedEvent -> highlightedEventId.value = event.eventId is TimelineEvents.SetHighlightedEvent -> highlightedEventId.value = event.eventId
is TimelineEvents.ToggleExpandGroup -> {
expandedGroups[event.event.identifier()] = expandedGroups[event.event.identifier()].orFalse().not()
}
} }
} }
@ -92,7 +82,7 @@ class TimelinePresenter @Inject constructor(
return TimelineState( return TimelineState(
highlightedEventId = highlightedEventId.value, highlightedEventId = highlightedEventId.value,
paginationState = paginationState.value, paginationState = paginationState.value,
timelineItems = timelineItemGrouper.group(timelineItems.value, expandedGroups).toImmutableList(), timelineItems = timelineItems.value,
eventSink = ::handleEvents eventSink = ::handleEvents
) )
} }

View file

@ -94,7 +94,7 @@ internal fun aTimelineItemEvent(
eventId: EventId = EventId("\$" + Random.nextInt().toString()), eventId: EventId = EventId("\$" + Random.nextInt().toString()),
isMine: Boolean = false, isMine: Boolean = false,
content: TimelineItemEventContent = aTimelineItemTextContent(), content: TimelineItemEventContent = aTimelineItemTextContent(),
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.First, groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
sendState: EventSendState = EventSendState.Sent(eventId), sendState: EventSendState = EventSendState.Sent(eventId),
): TimelineItem.Event { ): TimelineItem.Event {
return TimelineItem.Event( return TimelineItem.Event(

View file

@ -47,8 +47,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -95,7 +97,6 @@ fun TimelineView(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onMessageClicked: (TimelineItem.Event) -> Unit = {}, onMessageClicked: (TimelineItem.Event) -> Unit = {},
onMessageLongClicked: (TimelineItem.Event) -> Unit = {}, onMessageLongClicked: (TimelineItem.Event) -> Unit = {},
onExpandGroupClick: (TimelineItem.GroupedEvents) -> Unit = {},
) { ) {
fun onReachedLoadMore() { fun onReachedLoadMore() {
@ -119,7 +120,6 @@ fun TimelineView(
highlightedItem = state.highlightedEventId?.value, highlightedItem = state.highlightedEventId?.value,
onClick = onMessageClicked, onClick = onMessageClicked,
onLongClick = onMessageLongClicked, onLongClick = onMessageLongClicked,
onExpandGroupClick = onExpandGroupClick,
) )
if (index == state.timelineItems.lastIndex) { if (index == state.timelineItems.lastIndex) {
onReachedLoadMore() onReachedLoadMore()
@ -141,7 +141,6 @@ fun TimelineItemRow(
highlightedItem: String?, highlightedItem: String?,
onClick: (TimelineItem.Event) -> Unit, onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit, onLongClick: (TimelineItem.Event) -> Unit,
onExpandGroupClick: (TimelineItem.GroupedEvents) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
when (timelineItem) { when (timelineItem) {
@ -179,8 +178,10 @@ fun TimelineItemRow(
} }
} }
is TimelineItem.GroupedEvents -> { is TimelineItem.GroupedEvents -> {
val isExpanded = rememberSaveable(key = timelineItem.identifier()) { mutableStateOf(false) }
fun onExpandGroupClick() { fun onExpandGroupClick() {
onExpandGroupClick(timelineItem) isExpanded.value = !isExpanded.value
} }
Column(modifier = modifier.animateContentSize()) { Column(modifier = modifier.animateContentSize()) {
@ -190,11 +191,11 @@ fun TimelineItemRow(
count = timelineItem.events.size, count = timelineItem.events.size,
timelineItem.events.size timelineItem.events.size
), ),
isExpanded = timelineItem.expanded, isExpanded = isExpanded.value,
isHighlighted = !timelineItem.expanded && timelineItem.events.any { it.identifier() == highlightedItem }, isHighlighted = !isExpanded.value && timelineItem.events.any { it.identifier() == highlightedItem },
onClick = ::onExpandGroupClick, onClick = ::onExpandGroupClick,
) )
if (timelineItem.expanded) { if (isExpanded.value) {
Column { Column {
timelineItem.events.forEach { subGroupEvent -> timelineItem.events.forEach { subGroupEvent ->
TimelineItemRow( TimelineItemRow(
@ -202,7 +203,6 @@ fun TimelineItemRow(
highlightedItem = highlightedItem, highlightedItem = highlightedItem,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
onExpandGroupClick = {}
) )
} }
} }
@ -247,14 +247,16 @@ fun TimelineItemEventRow(
) { ) {
Row { Row {
if (!event.isMine) { if (!event.isMine) {
Spacer(modifier = Modifier.width(16.dp)) Spacer(modifier = Modifier.width(4.dp))
} }
Column(horizontalAlignment = contentAlignment) { Column(horizontalAlignment = contentAlignment) {
if (event.showSenderInformation) { if (event.showSenderInformation) {
MessageSenderInformation( MessageSenderInformation(
event.safeSenderName, event.safeSenderName,
event.senderAvatar, event.senderAvatar,
Modifier.zIndex(1f) Modifier
.zIndex(1f)
.offset(y = 12.dp)
) )
} }
val bubbleState = BubbleState( val bubbleState = BubbleState(
@ -282,7 +284,7 @@ fun TimelineItemEventRow(
reactionsState = event.reactionsState, reactionsState = event.reactionsState,
modifier = Modifier modifier = Modifier
.zIndex(1f) .zIndex(1f)
.offset(x = if (event.isMine) 0.dp else 20.dp, y = -(16.dp)) .offset(x = if (event.isMine) 0.dp else 20.dp, y = -(4.dp))
) )
} }
if (event.isMine) { if (event.isMine) {

View file

@ -20,7 +20,6 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@ -35,13 +34,16 @@ import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter 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 io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleStateProvider import io.element.android.features.messages.impl.timeline.model.bubble.BubbleStateProvider
import io.element.android.libraries.core.extensions.to01
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.ElementTheme import io.element.android.libraries.designsystem.theme.ElementTheme
import io.element.android.libraries.designsystem.theme.components.Surface import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
private val BUBBLE_RADIUS = 16.dp private val BUBBLE_RADIUS = 16.dp
@ -84,10 +86,9 @@ fun MessageEventBubble(
fun Modifier.offsetForItem(): Modifier { fun Modifier.offsetForItem(): Modifier {
return if (state.isMine) { return if (state.isMine) {
// FIXME setting y offset to -12.dp can overlap a state event displayed above. this
offset(y = -(12.dp))
} else { } else {
offset(x = 20.dp, y = -(12.dp)) offset(x = 20.dp)
} }
} }
@ -130,7 +131,7 @@ internal fun MessageEventBubbleDarkPreview(@PreviewParameter(BubbleStateProvider
@Composable @Composable
private fun ContentToPreview(state: BubbleState) { private fun ContentToPreview(state: BubbleState) {
// Due to y offset, surround with a Box // Due to position offset, surround with a Box
Box( Box(
modifier = Modifier modifier = Modifier
.size(width = 240.dp, height = 64.dp) .size(width = 240.dp, height = 64.dp)
@ -141,7 +142,18 @@ private fun ContentToPreview(state: BubbleState) {
state = state, state = state,
interactionSource = MutableInteractionSource(), interactionSource = MutableInteractionSource(),
) { ) {
Spacer(modifier = Modifier.size(width = 120.dp, height = 32.dp)) // Render the state as a text to better understand the previews
Box(
modifier = Modifier
.size(width = 120.dp, height = 32.dp)
.padding(horizontal = 12.dp, vertical = 6.dp),
contentAlignment = Alignment.Center,
) {
Text(
fontSize = 10.sp,
text = "${state.groupPosition.javaClass.simpleName} m:${state.isMine.to01()} h:${state.isHighlighted.to01()}"
)
}
} }
} }
} }

View file

@ -25,13 +25,18 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Error import androidx.compose.material.icons.filled.Error
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
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.dp
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.designsystem.ElementTextStyles import io.element.android.libraries.designsystem.ElementTextStyles
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.ElementTheme import io.element.android.libraries.designsystem.theme.ElementTheme
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.Text import io.element.android.libraries.designsystem.theme.components.Text
@ -48,7 +53,10 @@ fun TimelineEventTimestampView(
val hasMessageSendingFailed = event.sendState is EventSendState.SendingFailed val hasMessageSendingFailed = event.sendState is EventSendState.SendingFailed
val isMessageEdited = (event.content as? TimelineItemTextBasedContent)?.isEdited.orFalse() val isMessageEdited = (event.content as? TimelineItemTextBasedContent)?.isEdited.orFalse()
val tint = if (hasMessageSendingFailed) ElementTheme.colors.textActionCritical else null val tint = if (hasMessageSendingFailed) ElementTheme.colors.textActionCritical else null
Row(modifier = modifier.clickable(onClick = onClick)) { Row(
modifier = modifier.clickable(onClick = onClick),
verticalAlignment = Alignment.CenterVertically,
) {
if (isMessageEdited) { if (isMessageEdited) {
Text( Text(
stringResource(R.string.common_edited_suffix), stringResource(R.string.common_edited_suffix),
@ -68,3 +76,21 @@ fun TimelineEventTimestampView(
} }
} }
} }
@Preview
@Composable
internal fun TimelineEventTimestampViewLightPreview(@PreviewParameter(TimelineItemEventForTimestampViewProvider::class) event: TimelineItem.Event) =
ElementPreviewLight { ContentToPreview(event) }
@Preview
@Composable
internal fun TimelineEventTimestampViewDarkPreview(@PreviewParameter(TimelineItemEventForTimestampViewProvider::class) event: TimelineItem.Event) =
ElementPreviewDark { ContentToPreview(event) }
@Composable
private fun ContentToPreview(event: TimelineItem.Event) {
TimelineEventTimestampView(
event = event,
onClick = {}
)
}

View file

@ -0,0 +1,39 @@
/*
* 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.messages.impl.timeline.components
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
class TimelineItemEventForTimestampViewProvider : PreviewParameterProvider<TimelineItem.Event> {
override val values: Sequence<TimelineItem.Event>
get() = sequenceOf(
aTimelineItemEvent(),
// Sending failed
aTimelineItemEvent().copy(sendState = EventSendState.SendingFailed("AN_ERROR")),
// Edited
aTimelineItemEvent().copy(content = aTimelineItemTextContent().copy(isEdited = true)),
// Sending failed + Edited (not sure this is possible IRL, but should be covered by test)
aTimelineItemEvent().copy(
sendState = EventSendState.SendingFailed("AN_ERROR"),
content = aTimelineItemTextContent().copy(isEdited = true),
),
)
}

View file

@ -21,9 +21,13 @@ import io.element.android.features.messages.impl.timeline.diff.CacheInvalidator
import io.element.android.features.messages.impl.timeline.diff.MatrixTimelineItemsDiffCallback import io.element.android.features.messages.impl.timeline.diff.MatrixTimelineItemsDiffCallback
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemEventFactory import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemEventFactory
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemVirtualFactory import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemVirtualFactory
import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@ -38,9 +42,10 @@ class TimelineItemsFactory @Inject constructor(
private val dispatchers: CoroutineDispatchers, private val dispatchers: CoroutineDispatchers,
private val eventItemFactory: TimelineItemEventFactory, private val eventItemFactory: TimelineItemEventFactory,
private val virtualItemFactory: TimelineItemVirtualFactory, private val virtualItemFactory: TimelineItemVirtualFactory,
private val timelineItemGrouper: TimelineItemGrouper,
) { ) {
private val timelineItems = MutableStateFlow<List<TimelineItem>>(emptyList()) private val timelineItems = MutableStateFlow(emptyList<TimelineItem>().toImmutableList())
private val timelineItemsCache = arrayListOf<TimelineItem?>() private val timelineItemsCache = arrayListOf<TimelineItem?>()
// Items from rust sdk, used for diffing // Items from rust sdk, used for diffing
@ -49,7 +54,7 @@ class TimelineItemsFactory @Inject constructor(
private val lock = Mutex() private val lock = Mutex()
private val cacheInvalidator = CacheInvalidator(timelineItemsCache) private val cacheInvalidator = CacheInvalidator(timelineItemsCache)
fun flow(): StateFlow<List<TimelineItem>> = timelineItems.asStateFlow() fun flow(): StateFlow<ImmutableList<TimelineItem>> = timelineItems.asStateFlow()
suspend fun replaceWith( suspend fun replaceWith(
timelineItems: List<MatrixTimelineItem>, timelineItems: List<MatrixTimelineItem>,
@ -72,7 +77,8 @@ class TimelineItemsFactory @Inject constructor(
newTimelineItemStates.add(cacheItem) newTimelineItemStates.add(cacheItem)
} }
} }
this.timelineItems.emit(newTimelineItemStates) val result = timelineItemGrouper.group(newTimelineItemStates).toPersistentList()
this.timelineItems.emit(result)
} }
private fun calculateAndApplyDiff(newTimelineItems: List<MatrixTimelineItem>) { private fun calculateAndApplyDiff(newTimelineItems: List<MatrixTimelineItem>) {

View file

@ -16,10 +16,12 @@
package io.element.android.features.messages.impl.timeline.factories.event package io.element.android.features.messages.impl.timeline.factories.event
import io.element.android.features.messages.impl.timeline.groups.canBeDisplayedInBubbleBlock
import io.element.android.features.messages.impl.timeline.model.AggregatedReaction import io.element.android.features.messages.impl.timeline.model.AggregatedReaction
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions
import io.element.android.libraries.core.bool.orTrue
import io.element.android.libraries.designsystem.components.avatar.AvatarData 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.avatar.AvatarSize
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
@ -102,10 +104,39 @@ class TimelineItemEventFactory @Inject constructor(
val previousSender = prevTimelineItem?.event?.sender val previousSender = prevTimelineItem?.event?.sender
val nextSender = nextTimelineItem?.event?.sender val nextSender = nextTimelineItem?.event?.sender
val previousIsGroupable = prevTimelineItem?.canBeDisplayedInBubbleBlock().orTrue()
val nextIsGroupable = nextTimelineItem?.canBeDisplayedInBubbleBlock().orTrue()
return when { return when {
previousSender != currentSender && nextSender == currentSender -> TimelineItemGroupPosition.First previousSender != currentSender && nextSender == currentSender -> {
previousSender == currentSender && nextSender == currentSender -> TimelineItemGroupPosition.Middle if (nextIsGroupable) {
previousSender == currentSender && nextSender != currentSender -> TimelineItemGroupPosition.Last TimelineItemGroupPosition.First
} else {
TimelineItemGroupPosition.None
}
}
previousSender == currentSender && nextSender == currentSender -> {
if (previousIsGroupable) {
if (nextIsGroupable) {
TimelineItemGroupPosition.Middle
} else {
TimelineItemGroupPosition.Last
}
} else {
if (nextIsGroupable) {
TimelineItemGroupPosition.First
} else {
TimelineItemGroupPosition.None
}
}
}
previousSender == currentSender /* && nextSender != currentSender (== true) */ -> {
if (previousIsGroupable) {
TimelineItemGroupPosition.Last
} else {
TimelineItemGroupPosition.None
}
}
else -> TimelineItemGroupPosition.None else -> TimelineItemGroupPosition.None
} }
} }

View file

@ -0,0 +1,80 @@
/*
* 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.messages.impl.timeline.groups
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemProfileChangeContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRoomMembershipContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
/**
* Return true if the Event can be grouped in a collapse/expand block
* When [canBeGrouped] returns a value, [canBeDisplayedInBubbleBlock] MUST return the opposite value.
* Since the receiving type are not the same, the two functions exist.
*/
internal fun TimelineItem.Event.canBeGrouped(): Boolean {
return when (content) {
is TimelineItemTextBasedContent,
is TimelineItemEncryptedContent,
is TimelineItemImageContent,
is TimelineItemFileContent,
is TimelineItemVideoContent,
TimelineItemRedactedContent,
TimelineItemUnknownContent -> false
is TimelineItemProfileChangeContent,
is TimelineItemRoomMembershipContent,
is TimelineItemStateEventContent -> true
}
}
/**
* Return true if the Event can be grouped in a block of message bubbles.
* When [canBeDisplayedInBubbleBlock] returns a value, [canBeGrouped] MUST return the opposite value.
* Since the receiving type are not the same, the two functions exist.
*/
internal fun MatrixTimelineItem.Event.canBeDisplayedInBubbleBlock(): Boolean {
return when (event.content) {
is FailedToParseMessageLikeContent,
is MessageContent,
RedactedContent,
is StickerContent,
is UnableToDecryptContent -> true
is FailedToParseStateContent,
is ProfileChangeContent,
is RoomMembershipContent,
UnknownContent,
is StateContent -> false
}
}

View file

@ -17,28 +17,14 @@
package io.element.android.features.messages.impl.timeline.groups package io.element.android.features.messages.impl.timeline.groups
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEmoteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemNoticeContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemProfileChangeContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRoomMembershipContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.libraries.core.bool.orFalse
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import javax.inject.Inject import javax.inject.Inject
class TimelineItemGrouper @Inject constructor() { class TimelineItemGrouper @Inject constructor() {
/** /**
* Create a new list of [TimelineItem] by grouping some of them into [TimelineItem.GroupedEvents]. * Create a new list of [TimelineItem] by grouping some of them into [TimelineItem.GroupedEvents].
*/ */
fun group(from: List<TimelineItem>, expandedGroups: Map<String, Boolean>): List<TimelineItem> { fun group(from: List<TimelineItem>): List<TimelineItem> {
val result = mutableListOf<TimelineItem>() val result = mutableListOf<TimelineItem>()
val currentGroup = mutableListOf<TimelineItem.Event>() val currentGroup = mutableListOf<TimelineItem.Event>()
from.forEach { timelineItem -> from.forEach { timelineItem ->
@ -48,42 +34,24 @@ class TimelineItemGrouper @Inject constructor() {
// timelineItem cannot be grouped // timelineItem cannot be grouped
if (currentGroup.isNotEmpty()) { if (currentGroup.isNotEmpty()) {
// There is a pending group, create a TimelineItem.GroupedEvents if there is more than 1 Event in the pending group. // There is a pending group, create a TimelineItem.GroupedEvents if there is more than 1 Event in the pending group.
result.addGroup(currentGroup, expandedGroups) result.addGroup(currentGroup)
currentGroup.clear() currentGroup.clear()
} }
result.add(timelineItem) result.add(timelineItem)
} }
} }
if (currentGroup.isNotEmpty()) { if (currentGroup.isNotEmpty()) {
result.addGroup(currentGroup, expandedGroups) result.addGroup(currentGroup)
} }
return result return result
} }
private fun TimelineItem.Event.canBeGrouped(): Boolean {
return when (content) {
is TimelineItemEncryptedContent,
is TimelineItemImageContent,
TimelineItemRedactedContent,
is TimelineItemEmoteContent,
is TimelineItemNoticeContent,
is TimelineItemTextContent,
is TimelineItemFileContent,
is TimelineItemVideoContent,
TimelineItemUnknownContent -> false
is TimelineItemProfileChangeContent,
is TimelineItemRoomMembershipContent,
is TimelineItemStateEventContent -> true
}
}
} }
/** /**
* Will add a group if there is more than 1 item, else add the item to the list. * Will add a group if there is more than 1 item, else add the item to the list.
*/ */
private fun MutableList<TimelineItem>.addGroup( private fun MutableList<TimelineItem>.addGroup(
group: MutableList<TimelineItem.Event>, group: MutableList<TimelineItem.Event>
expandedGroups: Map<String, Boolean>,
) { ) {
if (group.size == 1) { if (group.size == 1) {
// Do not create a group with just 1 item, just add the item to the result // Do not create a group with just 1 item, just add the item to the result
@ -91,7 +59,6 @@ private fun MutableList<TimelineItem>.addGroup(
} else { } else {
add( add(
TimelineItem.GroupedEvents( TimelineItem.GroupedEvents(
expanded = expandedGroups[group.first().id + "_group"].orFalse(),
events = group.toImmutableList() events = group.toImmutableList()
) )
) )

View file

@ -68,10 +68,9 @@ sealed interface TimelineItem {
@Immutable @Immutable
data class GroupedEvents( data class GroupedEvents(
val expanded: Boolean,
val events: ImmutableList<Event>, val events: ImmutableList<Event>,
) : TimelineItem { ) : TimelineItem {
// use first id with a suffix // use last id with a suffix. Last will not change in cas of new event from backpagination.
val id = events.first().id + "_group" val id = events.last().id + "_group"
} }
} }

View file

@ -18,13 +18,48 @@ package io.element.android.features.messages.impl.timeline.model
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
/**
* Attribute for a TimelineItem, used to render successive events from the same sender differently.
*
* Possible sequences in the timeline will be:
*
* Only one Event:
* - [None]
*
* Two Events
* - [First]
* - [Last]
*
* Many Events:
* - [First]
* - [Middle] (repeated if necessary)
* - [Last]
*/
@Immutable @Immutable
sealed interface TimelineItemGroupPosition { sealed interface TimelineItemGroupPosition {
/**
* The event is part of a group of events from the same sender and is the first sent Event.
*/
object First : TimelineItemGroupPosition object First : TimelineItemGroupPosition
/**
* The event is part of a group of events from the same sender and is neither the first nor the last sent Event.
*/
object Middle : TimelineItemGroupPosition object Middle : TimelineItemGroupPosition
/**
* The event is part of a group of events from the same sender and is the last sent Event.
*/
object Last : TimelineItemGroupPosition object Last : TimelineItemGroupPosition
/**
* The event is not part of a group of events. Sender of previous event is different, and sender of next event is different.
*/
object None : TimelineItemGroupPosition object None : TimelineItemGroupPosition
/**
* Return true if the previous sender of the event is a different sender.
*/
fun isNew(): Boolean = when (this) { fun isNew(): Boolean = when (this) {
First, None -> true First, None -> true
else -> false else -> false

View file

@ -25,6 +25,7 @@ open class BubbleStateProvider : PreviewParameterProvider<BubbleState> {
TimelineItemGroupPosition.First, TimelineItemGroupPosition.First,
TimelineItemGroupPosition.Middle, TimelineItemGroupPosition.Middle,
TimelineItemGroupPosition.Last, TimelineItemGroupPosition.Last,
TimelineItemGroupPosition.None,
).map { groupPosition -> ).map { groupPosition ->
sequenceOf(false, true).map { isMine -> sequenceOf(false, true).map { isMine ->
sequenceOf(false, true).map { isHighlighted -> sequenceOf(false, true).map { isHighlighted ->

View file

@ -28,7 +28,6 @@ import io.element.android.features.messages.impl.actionlist.ActionListPresenter
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
import io.element.android.features.messages.impl.timeline.TimelinePresenter import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper
import io.element.android.features.messages.media.FakeLocalMediaFactory import io.element.android.features.messages.media.FakeLocalMediaFactory
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
@ -142,7 +141,6 @@ class MessagesPresenterTest {
) )
val timelinePresenter = TimelinePresenter( val timelinePresenter = TimelinePresenter(
timelineItemsFactory = aTimelineItemsFactory(), timelineItemsFactory = aTimelineItemsFactory(),
timelineItemGrouper = TimelineItemGrouper(),
room = matrixRoom, room = matrixRoom,
) )
val actionListPresenter = ActionListPresenter() val actionListPresenter = ActionListPresenter()

View file

@ -14,6 +14,8 @@
* limitations under the License. * limitations under the License.
*/ */
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.messages.attachments package io.element.android.features.messages.attachments
import androidx.media3.common.MimeTypes import androidx.media3.common.MimeTypes
@ -33,6 +35,7 @@ import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test

View file

@ -30,6 +30,7 @@ import io.element.android.features.messages.impl.timeline.factories.event.Timeli
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemEventFactory import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemEventFactory
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemDaySeparatorFactory import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemDaySeparatorFactory
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemVirtualFactory import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemVirtualFactory
import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper
import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter
import io.element.android.libraries.eventformatter.api.TimelineEventFormatter import io.element.android.libraries.eventformatter.api.TimelineEventFormatter
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
@ -56,7 +57,8 @@ internal fun aTimelineItemsFactory(): TimelineItemsFactory {
daySeparatorFactory = TimelineItemDaySeparatorFactory( daySeparatorFactory = TimelineItemDaySeparatorFactory(
FakeDaySeparatorFormatter() FakeDaySeparatorFormatter()
), ),
) ),
timelineItemGrouper = TimelineItemGrouper(),
) )
} }

View file

@ -14,6 +14,8 @@
* limitations under the License. * limitations under the License.
*/ */
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.messages.media.viewer package io.element.android.features.messages.media.viewer
import androidx.media3.common.MimeTypes import androidx.media3.common.MimeTypes
@ -29,6 +31,7 @@ import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.test.FAKE_DELAY_IN_MS import io.element.android.libraries.matrix.test.FAKE_DELAY_IN_MS
import io.element.android.libraries.matrix.test.media.FakeMediaLoader import io.element.android.libraries.matrix.test.media.FakeMediaLoader
import io.element.android.libraries.matrix.test.media.aMediaSource import io.element.android.libraries.matrix.test.media.aMediaSource
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test

View file

@ -23,13 +23,8 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.fixtures.aTimelineItemsFactory import io.element.android.features.messages.fixtures.aTimelineItemsFactory
import io.element.android.features.messages.impl.timeline.TimelineEvents import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelinePresenter import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.anEventTimelineItem
import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -38,7 +33,6 @@ class TimelinePresenterTest {
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val presenter = TimelinePresenter( val presenter = TimelinePresenter(
timelineItemsFactory = aTimelineItemsFactory(), timelineItemsFactory = aTimelineItemsFactory(),
timelineItemGrouper = TimelineItemGrouper(),
room = FakeMatrixRoom(), room = FakeMatrixRoom(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
@ -55,7 +49,6 @@ class TimelinePresenterTest {
fun `present - load more`() = runTest { fun `present - load more`() = runTest {
val presenter = TimelinePresenter( val presenter = TimelinePresenter(
timelineItemsFactory = aTimelineItemsFactory(), timelineItemsFactory = aTimelineItemsFactory(),
timelineItemGrouper = TimelineItemGrouper(),
room = FakeMatrixRoom(), room = FakeMatrixRoom(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
@ -78,7 +71,6 @@ class TimelinePresenterTest {
fun `present - set highlighted event`() = runTest { fun `present - set highlighted event`() = runTest {
val presenter = TimelinePresenter( val presenter = TimelinePresenter(
timelineItemsFactory = aTimelineItemsFactory(), timelineItemsFactory = aTimelineItemsFactory(),
timelineItemGrouper = TimelineItemGrouper(),
room = FakeMatrixRoom(), room = FakeMatrixRoom(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
@ -95,37 +87,4 @@ class TimelinePresenterTest {
assertThat(withoutHighlightedState.highlightedEventId).isNull() assertThat(withoutHighlightedState.highlightedEventId).isNull()
} }
} }
@Test
fun `present - expand and collapse grouped events`() = runTest {
val fakeTimeline = FakeMatrixTimeline(
initialTimelineItems = listOf(
MatrixTimelineItem.Event(anEventTimelineItem() /* This is a groupable event */),
MatrixTimelineItem.Event(anEventTimelineItem() /* This is a groupable event */),
)
)
val fakeRoom = FakeMatrixRoom(matrixTimeline = fakeTimeline)
val presenter = TimelinePresenter(
timelineItemsFactory = aTimelineItemsFactory(),
timelineItemGrouper = TimelineItemGrouper(),
room = fakeRoom,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
fakeTimeline.updateTimelineItems { it }
val loadedState = awaitItem()
val group1 = loadedState.timelineItems.first() as TimelineItem.GroupedEvents
assertThat(group1.expanded).isFalse()
loadedState.eventSink.invoke(TimelineEvents.ToggleExpandGroup(group1))
val withExpandedGroup = awaitItem()
val group2 = withExpandedGroup.timelineItems.first() as TimelineItem.GroupedEvents
assertThat(group2.expanded).isTrue()
withExpandedGroup.eventSink.invoke(TimelineEvents.ToggleExpandGroup(group2))
val withCollapsedGroup = awaitItem()
val group3 = withCollapsedGroup.timelineItems.first() as TimelineItem.GroupedEvents
assertThat(group3.expanded).isFalse()
}
}
} }

View file

@ -49,7 +49,7 @@ class TimelineItemGrouperTest {
@Test @Test
fun `test empty`() { fun `test empty`() {
val result = sut.group(emptyList(), emptyMap()) val result = sut.group(emptyList())
assertThat(result).isEmpty() assertThat(result).isEmpty()
} }
@ -60,7 +60,6 @@ class TimelineItemGrouperTest {
aNonGroupableItem, aNonGroupableItem,
aNonGroupableItem, aNonGroupableItem,
), ),
emptyMap()
) )
assertThat(result).isEqualTo( assertThat(result).isEqualTo(
listOf( listOf(
@ -77,12 +76,10 @@ class TimelineItemGrouperTest {
aGroupableItem.copy(id = AN_EVENT_ID_2.value), aGroupableItem.copy(id = AN_EVENT_ID_2.value),
aGroupableItem, aGroupableItem,
), ),
emptyMap()
) )
assertThat(result).isEqualTo( assertThat(result).isEqualTo(
listOf( listOf(
TimelineItem.GroupedEvents( TimelineItem.GroupedEvents(
expanded = false,
events = listOf( events = listOf(
aGroupableItem, aGroupableItem,
aGroupableItem.copy(id = AN_EVENT_ID_2.value), aGroupableItem.copy(id = AN_EVENT_ID_2.value),
@ -92,28 +89,6 @@ class TimelineItemGrouperTest {
) )
} }
@Test
fun `test groupables expanded`() {
val result = sut.group(
listOf(
aGroupableItem,
aGroupableItem.copy(id = AN_EVENT_ID_2.value),
),
mapOf("${AN_EVENT_ID_2.value}_group" to true)
)
assertThat(result).isEqualTo(
listOf(
TimelineItem.GroupedEvents(
expanded = true,
events = listOf(
aGroupableItem.copy(id = AN_EVENT_ID_2.value),
aGroupableItem,
).toImmutableList()
),
)
)
}
@Test @Test
fun `test 1 groupable, not group must be created`() { fun `test 1 groupable, not group must be created`() {
val listsToTest = listOf( val listsToTest = listOf(
@ -130,7 +105,7 @@ class TimelineItemGrouperTest {
listOf(aNonGroupableItemNoEvent), listOf(aNonGroupableItemNoEvent),
) )
listsToTest.forEach { listToTest -> listsToTest.forEach { listToTest ->
val result = sut.group(listToTest, emptyMap()) val result = sut.group(listToTest)
assertThat(result).isEqualTo(listToTest) assertThat(result).isEqualTo(listToTest)
} }
} }
@ -146,12 +121,10 @@ class TimelineItemGrouperTest {
aGroupableItem, aGroupableItem,
aGroupableItem, aGroupableItem,
), ),
emptyMap()
) )
assertThat(result).isEqualTo( assertThat(result).isEqualTo(
listOf( listOf(
TimelineItem.GroupedEvents( TimelineItem.GroupedEvents(
expanded = false,
events = listOf( events = listOf(
aGroupableItem, aGroupableItem,
aGroupableItem, aGroupableItem,
@ -159,7 +132,6 @@ class TimelineItemGrouperTest {
), ),
aNonGroupableItem, aNonGroupableItem,
TimelineItem.GroupedEvents( TimelineItem.GroupedEvents(
expanded = false,
events = listOf( events = listOf(
aGroupableItem, aGroupableItem,
aGroupableItem, aGroupableItem,

View file

@ -17,6 +17,7 @@
package io.element.android.libraries.core.extensions package io.element.android.libraries.core.extensions
fun Boolean.toOnOff() = if (this) "ON" else "OFF" fun Boolean.toOnOff() = if (this) "ON" else "OFF"
fun Boolean.to01() = if (this) "1" else "0"
inline fun <T> T.ooi(block: (T) -> Unit): T = also(block) inline fun <T> T.ooi(block: (T) -> Unit): T = also(block)

View file

@ -14,9 +14,12 @@
* limitations under the License. * limitations under the License.
*/ */
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.tests.testutils package io.element.android.tests.testutils
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestCoroutineScheduler import kotlinx.coroutines.test.TestCoroutineScheduler
import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:bb1648a000da198a30d3214adecf0b1266a150a226548ce77ac6d0eb001c65e2 oid sha256:7ddcd892538faa691ade67de5145392f89eaab4c903cc8fed00b24726753ed4e
size 5238 size 6945

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:44487cb1f6115b388bb043081e15d904a090277fca6edea05ccb7b6c54d56de2 oid sha256:cea39d32d858cd7b28885e634e746aea3652a56b665563e26f62346d9eaa1128
size 5761 size 7172

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:75ba23e1b82c3a5e4c3f4330a004b00d4eb2a2d3a27ce687ad6876c6e6e129be oid sha256:7eb000677f48efbd9dfbe373b221dbc98b2da65404e86aea42e79416cc032205
size 5547 size 7107

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:46067cba703ae02846a141dd53b888d48b6e55890cefed4cef4795c0b5910346 oid sha256:1daab66cf9beb8a5bb9c4cdea0db76fdc690b850bde1ada9679bfaf0167e6982
size 5871 size 7150

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:dbd968203eb9092f6ceab631da735a72bd15a44c3c8faaba45246a58bf3d9837 oid sha256:7ab56f3950f19ed24fcd2592157046eb878a2dde9e9831b8a3d846f5f3207dc0
size 5566 size 7062

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d7f03a7e0ac8e63116d1c4806f07266ace800f52c8f089846c515ff12488918f oid sha256:2c6e69cd6e3687ef724ff2c8f5151fb6679fd6395d866d7f944507c130e938eb
size 5875 size 7084

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:492b8ec89b02bbba9abb849d6b28f61d77f448d66d1eef5eba5a6f66db725897 oid sha256:d789e51e1f3c440f8577d465322c982a5a6aea54f7d1a7ab12e183ba841b0f14
size 5133 size 7274

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:ccf5cf29bdcb6016203c849ea1013de2ab42ec21c9b202c56dea2d7b2b75434b oid sha256:15100ea369eab89e99c4252b88e33f362cca287e6ef0799e1e3e3a8f4be6e227
size 5516 size 7327

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e666a4e785be44883e945e5ab22766c96358381467fc6860c3e8733a359d6801 oid sha256:92c2dc09ef213e2f55568f036d17e0ba81f9053a61774bd89ea27273c9a0aeb8
size 5462 size 7415

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:6fc64090e9c685246a0adc2a2250cb80fcc64cbe7d6578d63bc4827eaa50591e oid sha256:a035a78264d396b78c2653b89ab0edbc3ee6400d380eccd587f80cafdd57f165
size 5682 size 7335

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b584ab84ca09c1fe9c2ce8433a92adb8f1f6c595ccc702f10b976b81951b9048 oid sha256:012db6849c7458978d11f0c56b171dcc01993ac3bbc940639a609a04e5f591ad
size 5253 size 7093

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:ab7a53108da1eda0c1f24360d4c4073dca110dd3b368cc1f117cd3f351a385dc oid sha256:d429ec724142f73fa7846ec26a21b62e1a3466bd1e85193bdf48e749bf188978
size 5789 size 7253

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:ace5a91e960c6570647e2b8ad2de428ba474e48531a2f74ab267d07668d7a9e8 oid sha256:87de1686f0e84c9f00f80dc02a63ad6d0b0c360762a9d4f996b315407aec37d6
size 4928 size 6700

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:aec5d74e6f4d316c1ea124dc50e2abbfd22d6ee94b6d82519b3f9cab7a191dfa oid sha256:ab4af3d37dc161ccba7366517f60cb50923539615909c4bf76ec667095a4293c
size 5348 size 6712

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:dfaa4d6032641b332b111b20c81ec4bca917391cf122a06718fca7da849a7c94 oid sha256:a5962c60a9072b96cbd72e8ea9cf6344f715d8469a45f51fcdebacfec5344544
size 5124 size 6693

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:ac3eb3ebe9f2f30224389586f9b5901106b73cee9f5b4cdeb0f76b313ba0cd31 oid sha256:3ed947a2df42bc4cd7b0c181d608c69763897383f4846b2878e3df2705dfae0e
size 5384 size 6715

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:8e0eaf65f627b7be068311b0c8f52cfbe847571684a0c63dda3e7c0621eac42c oid sha256:544dac21d0af65c94f1e38ee2b0ff5a20dd3f12c40a5c41037c12f3af54df9ca
size 5133 size 6598

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:8359444a65d74682106930a7bbc6fb171acb95446cc5ff67eefa0dadc51e5932 oid sha256:dca857bfb1b6405488d96214b72e219f9aa4c4abd535cd1dd83b7efd7603c497
size 5397 size 6641

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d774fca7f28623d7e464c233e5e2282b5c7d608d7aa6084b2350a4f71765bc5a oid sha256:83613dcfe9968db44cabf918d6dc9ab011869c80d4bf089d5d4c46f7b6bedae4
size 4809 size 6968

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:32c81961535a22ce1fccc15865cd76bc9844168552a2205f7f7342398866b622 oid sha256:8d44801312b785f5c595a988124de6d70d65de01fb18c74876b1f6328e84c222
size 5109 size 6914

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:bd53dc603b4689eabe41e5d90ea0d8a70b769d60082ee57eee556ef9a7ea24ec oid sha256:e884fe9fd0c212caf0bfc745206ea277ff77037356e4e94b844e1cd1100a8861
size 4984 size 6955

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:43e08e7c243192571121595483ddc2dd44b56892afff5ae949ac224cad13e73f oid sha256:7abd5b98f55ca52bccd791196613029d2932520f062f08fd6356666b5fe1dbe8
size 5165 size 6878

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:7c61846e0037aa7a4d6f146fa4f2b441654570db082b3f506bb6f49aa01f3999 oid sha256:cfaed204b2cc5ee8d1ec54f84c446346c62e392ad2ae305359a552f5f9a427f1
size 4944 size 6755

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:137bfee9d776c8abff1a2428e3edc1c28cc7ca21d9849640e2aad2d945d7f7f2 oid sha256:a801ced0c93a02e5fa22ca021f78d3ab1834169745353aaacbaad4bc41bbb112
size 5361 size 6834

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:0e7e30c460d75815023ed02cd6b0fcff4a15881916f0056e47d9fd034bf3a181 oid sha256:121e9eb7013123e5647e351eda7d331ade8032ae5aa56bd1be943ee6c6acad53
size 41125 size 41444

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:81b126a3d322d70f06db15622e560a146cea2c81f5873987550aba0a5c5efffa oid sha256:e19a24fa27305822af8ec3a4dfac727eb1297ccbbeffec726664c6bc29b1a2a4
size 53314 size 53421

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b7e8b8ff0aeeb517c47dfd987579b73a7e862e9a67a5100532f39087d42a4fe4 oid sha256:9ab84b59725483b7187e91f3df18d1d55decbef7da37e186099027c6c98e3430
size 46056 size 46068

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b0ede4470399e2c7e6f3c99e59191a857c1e14254789307777359802980c59a1 oid sha256:5b388b6664ea0e5a86d6506a954cbcdc36c39054204739365eb0228b2e1d4fd4
size 193735 size 197778

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:c9fe22528aad68a5dffc8489fd1ec3138a8edde44fb23a1391f6e53a07d44fff oid sha256:dbeca10cc6a489dd20390bbc3bf5f2fa4f726342083621b8161a2a4448622ffc
size 193966 size 198122

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d559a454af6dfe367d1256dcd7ceeb32ae6fe168b76b1b5d8860d328e9752fb3 oid sha256:23ed23fbc4a47152c4d9b6373111c7de5497900e9ccb3382f99364bde2444032
size 51433 size 51724

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:1e45ec17c6353f96f0f777f8c638da010dbd062c680175d8ae80f6e700a6f928 oid sha256:8a715939660ec1a2bea4c5a3e9d0a3b4b0bc45f137fae4c3210476145346a3a9
size 72941 size 73507

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:572d6f11354e2b1f02cb0979b35e2c798abe2d3b63f852ca6b868471ceecaed0 oid sha256:f3094dab56dd2c46a5c34162e717afcf674823be080959ec5e852227946237ff
size 42964 size 42954

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b25f0497c08515125896756c89c5bc9e1757b25ec50478cd5cb67cdfd00c2dec oid sha256:44534d8513114e666e7faa341a81184420fd84e8c3723c2d17236949f31e2fdb
size 55019 size 55118

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:3f22479bb2c6bd35c6f927c11ff19e644226df3d2a99b9c6b23f5bd679178d5a oid sha256:f988cd33ae74aa292f26f0c6f11b5fcec8ff298005423161921e912a5e416c97
size 39151 size 39341

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:6920b50647111a70215c74ca27be736d6c208c0801eb4bcd323abcb33e903aea oid sha256:9a65842cd12943594ed28ffba0a958ff076d6c6492533bfc5ffd395b915b2b30
size 56229 size 56467

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a01bde6db3ce00e84e9bb5d6a8179c8d97af9dde939f6e31aa81c955dbdbf3a3 oid sha256:f7822ba6ecc9e7cf7bfdb4446b6796dce685d2417f6ed29b6ef5015438e09ace
size 40586 size 40935

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:3f12a13dd0fb3b569ce332ebc4e00fb55a6402bbf2b2a927b02abb4056c5e5c3 oid sha256:d3d8fae3d49c61850eef15c762d1f9615a735ca1943e78aa4dc116eaa830c02a
size 53043 size 53182

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:c50bb17bf530de6472d44232b0248c2a5761c67fde785b3aee6eb3f77fa4103e oid sha256:ebc4e13d35c918274f35445db57955780bfdc037d5bd85a98eddb9b3bac42d5e
size 45948 size 45846

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:c0f6c785a1e20160355c72356744dedba65bda3d453fbd9772390350eaf6cc6e oid sha256:ed3f266ce286216deca3747ded2b46bd7ef37c707145d1ae676e8afe227850b2
size 195504 size 199651

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:37c702936170a0e4b37d608cf62b92c3b60b93950d4e9f32633af77e3ce4fdc9 oid sha256:685a607ef2c644b8844a1a83bc9427040f3890b4b9f777aa80592c98f0a96d87
size 195775 size 199963

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e0b885810087916d3fefe699e5abc5125138ad74c6377f560e010e7fe68892da oid sha256:d5975a89ee4cf263317079be95b6f6cc199bfbbcf75ac88d622989bfd859c813
size 51388 size 51801

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:adcb71f4b9639287beabf35c9e91837bebee8f6b070fa77c0e6520fdf24faaae oid sha256:0375a01da0c470b8379d2edcfb282b4b55f5cee86dc9da3efbe66fbadf7019a5
size 73679 size 74079

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:cb24bbba2e17417f4f3459353ee2c762517acfae2eada90c47b559828bad16a0 oid sha256:f085dfb5b0147374af21b8df3f4cd2b9930e638188add6f3a4fa94008747c7ee
size 42411 size 42553

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d75c877c55c0f622aabee5ed8428b8a980cbc28daacb2127f2a17cc29db62a9d oid sha256:7ec8c64bb4500bce08725d74753f45db93a369095626064ecdf9a83122aea326
size 55057 size 55285

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a6edb33e04054aba49ea978d6b2c0d0c25332c4dd901cf2cadd6c368076e957d oid sha256:54046d58d89dd8f44e8e2fdb83306682fd6305a32ba91c06c9bcd042e99bbdd3
size 38687 size 38928

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:996016191220540fde3d8aaa3124a074a6eaaf6e87c43bf0d57d32992faf47fc oid sha256:a96004c7012bd1b83eb395511472a6c93f410f9504656f4e3621d7809eccca9f
size 56342 size 56483

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9c2c642f1595f503a1deb1c241f14543d7f15221baa1d0937595aa023f36a874 oid sha256:49759fbb46a966d0e0f1c793545e320e7588ac482399a12ac45762474e6781f8
size 44765 size 45579

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:1afd2c42012017f154fdf031d4a5fc7384fa5f239b56cf4567f42fd579806c9a oid sha256:cdca9e94c071c20753aa2076f6c3a7ed931a2c1e90a78c29e00f27f7af99dfe8
size 45363 size 45758

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9481f2994885a7649aa54d1c0043c44fe390004a7b58db4fc9d0e3e70bfc258a oid sha256:4e47966820a6a77efece51aaac5adbe3dd1dad1880b1feb7da2e86e36b386f7e
size 43537 size 43815

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:220b9ac66b74724887d7b0bbc082b4d0240d9fc25a068d33491eb83fe76e45fd oid sha256:bc3fc4c97a43c5786a75328e35828ed0e0cfd74ffab673832053ecc11db9b5e0
size 43958 size 44852

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9aa83ed14fb23495e79500a1e7d9dee8215efe67eec89e65e382d4f695eac68b oid sha256:7f82a51b6b6ed4ac030c4602da9da2629010169e998aaba16f9e05c652f6010d
size 44871 size 45283

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:aa4970f995c6ee618be3d868ff5a34442352a7f4493cad345093b889daf0d4c2 oid sha256:5d18843f61267ae7385c23113be5c5c3d4376d95a843eef126ed670877f69b80
size 42042 size 42281