Update rust sdk to 0.1.29 (new back pagination status api)

This commit is contained in:
ganfra 2023-07-07 15:24:43 +02:00
parent 220a6087f9
commit 98cf3e98c5
15 changed files with 67 additions and 128 deletions

View file

@ -23,7 +23,6 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItemReac
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingModel
import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel
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
@ -93,15 +92,9 @@ internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList
aGroupedEvents(), aGroupedEvents(),
// A day separator // A day separator
aTimelineItemDaySeparator(), aTimelineItemDaySeparator(),
// Loading
aTimelineItemLoading(),
) )
} }
fun aTimelineItemLoading(): TimelineItem.Virtual {
return TimelineItem.Virtual("virtual_loading", TimelineItemLoadingModel)
}
fun aTimelineItemDaySeparator(): TimelineItem.Virtual { fun aTimelineItemDaySeparator(): TimelineItem.Virtual {
return TimelineItem.Virtual("virtual_day", aTimelineItemDaySeparatorModel("Today")) return TimelineItem.Virtual("virtual_day", aTimelineItemDaySeparatorModel("Today"))
} }

View file

@ -32,7 +32,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@ -59,6 +59,7 @@ import io.element.android.features.messages.impl.timeline.components.TimelineIte
import io.element.android.features.messages.impl.timeline.components.TimelineItemStateEventRow import io.element.android.features.messages.impl.timeline.components.TimelineItemStateEventRow
import io.element.android.features.messages.impl.timeline.components.TimelineItemVirtualRow import io.element.android.features.messages.impl.timeline.components.TimelineItemVirtualRow
import io.element.android.features.messages.impl.timeline.components.group.GroupHeaderView import io.element.android.features.messages.impl.timeline.components.group.GroupHeaderView
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineLoadingMoreIndicator
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.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentProvider import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentProvider
@ -114,11 +115,11 @@ fun TimelineView(
reverseLayout = true, reverseLayout = true,
contentPadding = PaddingValues(vertical = 8.dp), contentPadding = PaddingValues(vertical = 8.dp),
) { ) {
itemsIndexed( items(
items = state.timelineItems, items = state.timelineItems,
contentType = { _, timelineItem -> timelineItem.contentType() }, contentType = { timelineItem -> timelineItem.contentType() },
key = { _, timelineItem -> timelineItem.identifier() }, key = { timelineItem -> timelineItem.identifier() },
) { index, timelineItem -> ) { timelineItem ->
TimelineItemRow( TimelineItemRow(
timelineItem = timelineItem, timelineItem = timelineItem,
highlightedItem = state.highlightedEventId?.value, highlightedItem = state.highlightedEventId?.value,
@ -132,8 +133,14 @@ fun TimelineView(
onTimestampClicked = onTimestampClicked, onTimestampClicked = onTimestampClicked,
onSwipeToReply = onSwipeToReply, onSwipeToReply = onSwipeToReply,
) )
if (index == state.timelineItems.lastIndex) { }
onReachedLoadMore() if (state.paginationState.canBackPaginate) {
// Do not use key parameter to avoid wrong positioning
item(contentType = "TimelineLoadingMoreIndicator") {
TimelineLoadingMoreIndicator()
LaunchedEffect(Unit) {
onReachedLoadMore()
}
} }
} }
} }

View file

@ -22,7 +22,7 @@ import io.element.android.features.messages.impl.timeline.components.virtual.Tim
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineLoadingMoreIndicator import io.element.android.features.messages.impl.timeline.components.virtual.TimelineLoadingMoreIndicator
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.virtual.TimelineItemDaySeparatorModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
@Composable @Composable
fun TimelineItemVirtualRow( fun TimelineItemVirtualRow(
@ -30,8 +30,7 @@ fun TimelineItemVirtualRow(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
when (virtual.model) { when (virtual.model) {
is TimelineItemLoadingModel -> TimelineLoadingMoreIndicator(modifier)
is TimelineItemDaySeparatorModel -> TimelineItemDaySeparatorView(virtual.model, modifier) is TimelineItemDaySeparatorModel -> TimelineItemDaySeparatorView(virtual.model, modifier)
else -> return TimelineItemReadMarkerModel -> return
} }
} }

View file

@ -106,7 +106,7 @@ class TimelineItemsFactory @Inject constructor(
val timelineItemState = val timelineItemState =
when (val currentTimelineItem = timelineItems[index]) { when (val currentTimelineItem = timelineItems[index]) {
is MatrixTimelineItem.Event -> eventItemFactory.create(currentTimelineItem, index, timelineItems) is MatrixTimelineItem.Event -> eventItemFactory.create(currentTimelineItem, index, timelineItems)
is MatrixTimelineItem.Virtual -> virtualItemFactory.create(currentTimelineItem, index, timelineItems) is MatrixTimelineItem.Virtual -> virtualItemFactory.create(currentTimelineItem, index)
MatrixTimelineItem.Other -> null MatrixTimelineItem.Other -> null
} }
timelineItemsCache[index] = timelineItemState timelineItemsCache[index] = timelineItemState

View file

@ -17,9 +17,7 @@
package io.element.android.features.messages.impl.timeline.factories.virtual package io.element.android.features.messages.impl.timeline.factories.virtual
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.virtual.TimelineItemLoadingModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemUnknownVirtualModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemVirtualModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemVirtualModel
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
@ -30,23 +28,19 @@ class TimelineItemVirtualFactory @Inject constructor(
) { ) {
fun create( fun create(
currentTimelineItem: MatrixTimelineItem.Virtual, virtualTimelineItem: MatrixTimelineItem.Virtual,
index: Int, index: Int,
timelineItems: List<MatrixTimelineItem>,
): TimelineItem.Virtual { ): TimelineItem.Virtual {
return TimelineItem.Virtual( return TimelineItem.Virtual(
id = "virtual_item_$index", id = "virtual_item_$index",
model = currentTimelineItem.computeModel(index) model = virtualTimelineItem.computeModel()
) )
} }
private fun MatrixTimelineItem.Virtual.computeModel(index: Int): TimelineItemVirtualModel { private fun MatrixTimelineItem.Virtual.computeModel(): TimelineItemVirtualModel {
return when (val inner = virtual) { return when (val inner = virtual) {
is VirtualTimelineItem.DayDivider -> daySeparatorFactory.create(inner) is VirtualTimelineItem.DayDivider -> daySeparatorFactory.create(inner)
is VirtualTimelineItem.ReadMarker -> TimelineItemReadMarkerModel is VirtualTimelineItem.ReadMarker -> TimelineItemReadMarkerModel
is VirtualTimelineItem.LoadingIndicator -> TimelineItemLoadingModel
is VirtualTimelineItem.TimelineStart -> TimelineItemReadMarkerModel
else -> TimelineItemUnknownVirtualModel
} }
} }
} }

View file

@ -1,21 +0,0 @@
/*
* 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.model.virtual
object TimelineItemLoadingModel : TimelineItemVirtualModel {
override val type: String = "TimelineItemLoadingModel"
}

View file

@ -1,21 +0,0 @@
/*
* 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.model.virtual
object TimelineItemTimelineStartModel : TimelineItemVirtualModel {
override val type: String = "TimelineItemTimelineStartModel"
}

View file

@ -1,21 +0,0 @@
/*
* 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.model.virtual
object TimelineItemUnknownVirtualModel : TimelineItemVirtualModel {
override val type: String = "TimelineItemUnknownVirtualModel"
}

View file

@ -145,7 +145,7 @@ jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" } appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" } molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" }
timber = "com.jakewharton.timber:timber:5.0.1" timber = "com.jakewharton.timber:timber:5.0.1"
matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.28" matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.29"
sqldelight-driver-android = { module = "com.squareup.sqldelight:android-driver", version.ref = "sqldelight" } sqldelight-driver-android = { module = "com.squareup.sqldelight:android-driver", version.ref = "sqldelight" }
sqldelight-driver-jvm = { module = "com.squareup.sqldelight:sqlite-driver", version.ref = "sqldelight" } sqldelight-driver-jvm = { module = "com.squareup.sqldelight:sqlite-driver", version.ref = "sqldelight" }
sqldelight-coroutines = { module = "com.squareup.sqldelight:coroutines-extensions", version.ref = "sqldelight" } sqldelight-coroutines = { module = "com.squareup.sqldelight:coroutines-extensions", version.ref = "sqldelight" }

View file

@ -24,7 +24,4 @@ sealed interface VirtualTimelineItem {
object ReadMarker : VirtualTimelineItem object ReadMarker : VirtualTimelineItem
object LoadingIndicator : VirtualTimelineItem
object TimelineStart : VirtualTimelineItem
} }

View file

@ -39,6 +39,7 @@ import io.element.android.libraries.matrix.impl.core.toProgressWatcher
import io.element.android.libraries.matrix.impl.room.location.toInner import io.element.android.libraries.matrix.impl.room.location.toInner
import io.element.android.libraries.matrix.impl.media.map import io.element.android.libraries.matrix.impl.media.map
import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline
import io.element.android.libraries.matrix.impl.timeline.backPaginationStatusFlow
import io.element.android.libraries.matrix.impl.timeline.timelineDiffFlow import io.element.android.libraries.matrix.impl.timeline.timelineDiffFlow
import io.element.android.services.toolbox.api.systemclock.SystemClock import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -111,6 +112,12 @@ class RustMatrixRoom(
_syncUpdateFlow.value = systemClock.epochMillis() _syncUpdateFlow.value = systemClock.epochMillis()
_timeline.postDiff(it) _timeline.postDiff(it)
}.launchIn(this) }.launchIn(this)
innerRoom.backPaginationStatusFlow()
.onEach {
_timeline.postPaginationStatus(it)
}.launchIn(this)
fetchMembers() fetchMembers()
} }
isInit.value = true isInit.value = true

View file

@ -16,9 +16,7 @@
package io.element.android.libraries.matrix.impl.timeline package io.element.android.libraries.matrix.impl.timeline
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
@ -28,7 +26,6 @@ import org.matrix.rustcomponents.sdk.TimelineDiff
import org.matrix.rustcomponents.sdk.TimelineItem import org.matrix.rustcomponents.sdk.TimelineItem
internal class MatrixTimelineDiffProcessor( internal class MatrixTimelineDiffProcessor(
private val paginationState: MutableStateFlow<MatrixTimeline.PaginationState>,
private val timelineItems: MutableStateFlow<List<MatrixTimelineItem>>, private val timelineItems: MutableStateFlow<List<MatrixTimelineItem>>,
private val timelineItemFactory: MatrixTimelineItemMapper, private val timelineItemFactory: MatrixTimelineItemMapper,
) { ) {
@ -40,7 +37,6 @@ internal class MatrixTimelineDiffProcessor(
updateTimelineItems { updateTimelineItems {
val mappedItems = items.map { it.asMatrixTimelineItem() } val mappedItems = items.map { it.asMatrixTimelineItem() }
addAll(mappedItems) addAll(mappedItems)
updateBackPaginationState()
} }
initLatch.complete(Unit) initLatch.complete(Unit)
} }
@ -50,29 +46,9 @@ internal class MatrixTimelineDiffProcessor(
initLatch.await() initLatch.await()
updateTimelineItems { updateTimelineItems {
applyDiff(diff) applyDiff(diff)
updateBackPaginationState()
} }
} }
private fun updateBackPaginationState(virtualItem: VirtualTimelineItem?) {
val currentPaginationState = paginationState.value
val newPaginationState = when (virtualItem) {
VirtualTimelineItem.LoadingIndicator -> currentPaginationState.copy(
isBackPaginating = true,
canBackPaginate = true
)
VirtualTimelineItem.TimelineStart -> currentPaginationState.copy(
isBackPaginating = false,
canBackPaginate = false
)
else -> currentPaginationState.copy(
isBackPaginating = false,
canBackPaginate = true
)
}
paginationState.value = newPaginationState
}
private suspend fun updateTimelineItems(block: MutableList<MatrixTimelineItem>.() -> Unit) = private suspend fun updateTimelineItems(block: MutableList<MatrixTimelineItem>.() -> Unit) =
mutex.withLock { mutex.withLock {
val mutableTimelineItems = timelineItems.value.toMutableList() val mutableTimelineItems = timelineItems.value.toMutableList()
@ -125,13 +101,6 @@ internal class MatrixTimelineDiffProcessor(
} }
} }
private fun List<MatrixTimelineItem>.updateBackPaginationState() {
when (val firstItem = firstOrNull()) {
is MatrixTimelineItem.Virtual -> updateBackPaginationState(firstItem.virtual)
else -> updateBackPaginationState(null)
}
}
private fun TimelineItem.asMatrixTimelineItem(): MatrixTimelineItem { private fun TimelineItem.asMatrixTimelineItem(): MatrixTimelineItem {
return timelineItemFactory.map(this) return timelineItemFactory.map(this)
} }

View file

@ -21,6 +21,8 @@ import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.buffer import kotlinx.coroutines.flow.buffer
import org.matrix.rustcomponents.sdk.BackPaginationStatus
import org.matrix.rustcomponents.sdk.BackPaginationStatusListener
import org.matrix.rustcomponents.sdk.Room import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.TimelineDiff import org.matrix.rustcomponents.sdk.TimelineDiff
import org.matrix.rustcomponents.sdk.TimelineItem import org.matrix.rustcomponents.sdk.TimelineItem
@ -37,3 +39,13 @@ internal fun Room.timelineDiffFlow(onInitialList: suspend (List<TimelineItem>) -
onInitialList(result.items) onInitialList(result.items)
result.itemsStream result.itemsStream
}.buffer(Channel.UNLIMITED) }.buffer(Channel.UNLIMITED)
internal fun Room.backPaginationStatusFlow(): Flow<BackPaginationStatus> =
mxCallbackFlow {
val listener = object : BackPaginationStatusListener {
override fun onUpdate(status: BackPaginationStatus) {
trySendBlocking(status)
}
}
subscribeToBackPaginationStatus(listener)
}.buffer(Channel.UNLIMITED)

View file

@ -31,8 +31,10 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.flow.sample import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.BackPaginationStatus
import org.matrix.rustcomponents.sdk.PaginationOptions import org.matrix.rustcomponents.sdk.PaginationOptions
import org.matrix.rustcomponents.sdk.Room import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.TimelineDiff import org.matrix.rustcomponents.sdk.TimelineDiff
@ -65,7 +67,6 @@ class RustMatrixTimeline(
) )
private val timelineDiffProcessor = MatrixTimelineDiffProcessor( private val timelineDiffProcessor = MatrixTimelineDiffProcessor(
paginationState = _paginationState,
timelineItems = _timelineItems, timelineItems = _timelineItems,
timelineItemFactory = timelineItemFactory, timelineItemFactory = timelineItemFactory,
) )
@ -83,6 +84,31 @@ class RustMatrixTimeline(
timelineDiffProcessor.postDiff(timelineDiff) timelineDiffProcessor.postDiff(timelineDiff)
} }
internal fun postPaginationStatus(status: BackPaginationStatus) {
_paginationState.getAndUpdate { currentPaginationState ->
when (status) {
BackPaginationStatus.IDLE -> {
currentPaginationState.copy(
isBackPaginating = false,
canBackPaginate = true
)
}
BackPaginationStatus.PAGINATING -> {
currentPaginationState.copy(
isBackPaginating = true,
canBackPaginate = true
)
}
BackPaginationStatus.TIMELINE_START_REACHED -> {
currentPaginationState.copy(
isBackPaginating = false,
canBackPaginate = false
)
}
}
}
}
override suspend fun fetchDetailsForEvent(eventId: EventId): Result<Unit> = withContext(coroutineDispatchers.io) { override suspend fun fetchDetailsForEvent(eventId: EventId): Result<Unit> = withContext(coroutineDispatchers.io) {
runCatching { runCatching {
innerRoom.fetchDetailsForEvent(eventId.value) innerRoom.fetchDetailsForEvent(eventId.value)

View file

@ -24,9 +24,7 @@ class VirtualTimelineItemMapper {
fun map(virtualTimelineItem: RustVirtualTimelineItem): VirtualTimelineItem { fun map(virtualTimelineItem: RustVirtualTimelineItem): VirtualTimelineItem {
return when (virtualTimelineItem) { return when (virtualTimelineItem) {
is RustVirtualTimelineItem.DayDivider -> VirtualTimelineItem.DayDivider(virtualTimelineItem.ts.toLong()) is RustVirtualTimelineItem.DayDivider -> VirtualTimelineItem.DayDivider(virtualTimelineItem.ts.toLong())
RustVirtualTimelineItem.LoadingIndicator -> VirtualTimelineItem.LoadingIndicator
RustVirtualTimelineItem.ReadMarker -> VirtualTimelineItem.ReadMarker RustVirtualTimelineItem.ReadMarker -> VirtualTimelineItem.ReadMarker
RustVirtualTimelineItem.TimelineStart -> VirtualTimelineItem.TimelineStart
} }
} }
} }